From 19908511ae3ac8652041579645f451cfff4ddf23 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Thu, 12 Nov 2026 18:15:46 +0200 Subject: [PATCH 1/4] Add multiblocks, machine visuals, recipe controls and face restrictions --- build.gradle | 14 +- docs/capabilities.md | 19 + docs/gui-screens.md | 25 +- docs/local-development.md | 40 + docs/machine-companion.md | 9 +- docs/mod-author-guide.md | 6 + docs/multiblocks.md | 123 ++ docs/xml-machines.md | 417 +++++- scripts/companion.gradle | 21 + .../moddev/mc/companion/CompanionMod.java | 2 + .../moddev/mc/companion/MultiblockDemo.java | 96 ++ .../mc/companion/MultiblockDemoEntity.java | 114 ++ .../blockstates/multiblock_demo.json | 20 + .../blockstates/multiblock_part.json | 20 + .../assets/mmdlib_companion/lang/en_us.json | 9 +- .../models/block/multiblock_demo.json | 6 + .../models/block/multiblock_part.json | 6 + .../models/item/multiblock_demo.json | 3 + .../mmdlib/screen/multiblock_demo.xml | 20 + .../mc/companion/test/DisabledFaceTests.java | 167 +++ .../test/MachineRecipeLoadingTests.java | 346 ++--- .../companion/test/MultiblockClientSmoke.java | 197 +++ .../mc/companion/test/MultiblockTests.java | 393 ++++++ .../api/automation/AutomationInventory.java | 6 +- .../mc/lib/api/automation/ResourceGroup.java | 2 +- .../mc/lib/api/capabilities/EnergyBuffer.java | 493 +++---- .../mc/lib/api/capabilities/FaceMask.java | 24 + .../mc/lib/api/capabilities/FluidStorage.java | 837 ++++++------ .../mc/lib/api/capabilities/ItemStorage.java | 11 + .../mc/lib/api/capabilities/SideConfig.java | 152 ++- .../mc/lib/api/redstone/RedstoneControl.java | 75 +- .../moddev/mc/lib/api/util/ChangeSignal.java | 31 + .../mc/lib/client/jei/JeiGhostRenderer.java | 102 ++ .../mc/lib/client/jei/MMDLibJeiPlugin.java | 188 +-- .../moddev/mc/lib/client/jei/MachineJei.java | 54 +- .../lib/client/jei/MachineRecipeCategory.java | 420 +++--- .../lib/client/jei/MachineRecipeLayout.java | 49 + .../lib/gui/client/BuiltinElementClients.java | 5 + .../mc/lib/gui/client/ElementBehavior.java | 9 + .../mc/lib/gui/client/ElementInput.java | 32 + .../mc/lib/gui/client/FaceConfigRenderer.java | 185 +-- .../moddev/mc/lib/gui/client/GhostItems.java | 6 +- .../mc/lib/gui/client/MachineScreen.java | 18 +- .../gui/client/RecipeSelectorBehavior.java | 205 +++ .../mc/lib/gui/client/RedstoneDrawer.java | 70 +- .../mc/lib/gui/client/SideConfigBehavior.java | 15 +- .../mc/lib/gui/client/SideConfigDrawer.java | 19 +- .../gui/client/SideConfigPresentation.java | 4 + .../mc/lib/gui/client/SideConfigRenderer.java | 5 + .../mc/lib/gui/load/BuiltinElements.java | 7 + .../model/elements/RecipeSelectorElement.java | 273 ++++ .../gui/model/elements/SideConfigElement.java | 36 +- .../mc/lib/gui/runtime/AutomationMenu.java | 17 +- .../mc/lib/gui/runtime/AutomationNetwork.java | 23 +- .../mc/lib/gui/runtime/MachineMenu.java | 26 +- .../mc/lib/gui/runtime/RedstoneMenu.java | 28 +- .../mc/lib/gui/runtime/RedstoneNetwork.java | 14 +- .../moddev/mc/lib/gui/runtime/ScreenHost.java | 11 + .../mc/lib/gui/runtime/ScreenNetwork.java | 2 +- .../mc/lib/gui/runtime/ScreenRuntime.java | 19 +- .../lib/machine/definition/MachineColors.java | 68 + .../machine/definition/MachineDefinition.java | 94 +- .../definition/MachineDefinitions.java | 195 ++- .../definition/MachineScreenGenerators.java | 15 +- .../lib/machine/feature/RecipeProcessor.java | 1167 ++++++++++------- .../machine/ingredient/MachineIngredient.java | 311 ++--- .../lib/machine/inventory/GhostItemGroup.java | 80 ++ .../mc/lib/machine/inventory/ItemGroup.java | 114 +- .../machine/inventory/MachineStorageKeys.java | 25 +- .../lib/machine/multiblock/FixedLayout.java | 19 + .../lib/machine/multiblock/LinkedMember.java | 7 + .../mc/lib/machine/multiblock/MemberLink.java | 32 + .../multiblock/MultiblockController.java | 6 + .../multiblock/MultiblockDefinition.java | 65 + .../multiblock/MultiblockInstance.java | 432 ++++++ .../machine/multiblock/ResolvedStructure.java | 43 + .../machine/multiblock/StructureMember.java | 18 + .../multiblock/StructureMembership.java | 13 + .../lib/machine/multiblock/StructurePort.java | 23 + .../multiblock/StructureRequirement.java | 12 + .../machine/multiblock/StructureResolver.java | 30 + .../multiblock/StructureTransform.java | 46 + .../mc/lib/machine/neoforge/BaseMachine.java | 64 +- .../machine/neoforge/DefinedMachineBlock.java | 7 + .../neoforge/DefinedMachineEntity.java | 158 ++- .../neoforge/GeneratedMachineRecipes.java | 477 +++---- .../neoforge/MachineInventoryLifecycle.java | 8 +- .../machine/neoforge/MachineResourcePack.java | 417 +++--- .../machine/neoforge/MachineResources.java | 11 + .../machine/neoforge/MachineVisualState.java | 164 +++ .../lib/machine/neoforge/MultiblockBlock.java | 120 ++ .../lib/machine/neoforge/MultiblockItem.java | 35 + .../machine/neoforge/MultiblockMembers.java | 91 ++ .../neoforge/MultiblockPartEntity.java | 67 + .../machine/neoforge/MultiblockPlacement.java | 166 +++ .../lib/machine/neoforge/MultiblockPorts.java | 209 +++ .../machine/neoforge/MultiblockResources.java | 22 + .../lib/machine/neoforge/StorageGroups.java | 7 + .../client/ConditionalMachineModel.java | 216 +++ .../neoforge/client/FaceTextureModel.java | 112 ++ .../neoforge/client/MaskedFaceGeometry.java | 100 ++ .../presentation/BuiltinVisualVariables.java | 119 ++ .../presentation/ConditionalFaceTexture.java | 14 + .../presentation/ConditionalModelPart.java | 7 + .../machine/presentation/EmissiveMask.java | 68 + .../machine/presentation/VisualCondition.java | 228 ++++ .../presentation/VisualVariableProvider.java | 7 + .../machine/presentation/VisualVariables.java | 129 ++ .../mc/lib/machine/recipe/CyclePlan.java | 971 +++++++------- .../mc/lib/machine/recipe/InputClaims.java | 121 +- .../mc/lib/machine/recipe/RecipeMatcher.java | 393 +++--- .../resources/assets/mmdlib/lang/en_us.json | 21 +- .../mmdlib/machine/base_powered_machine.xml | 5 +- src/main/resources/mmdlib/schema/screen.xsd | 11 + .../moddev/mc/lib/smoke/CyclePlanSmoke.java | 592 ++++----- .../mc/lib/smoke/ReusableInputSmoke.java | 194 +++ .../client/jei/MachineRecipeLayoutTest.java | 73 ++ .../mc/lib/gui/ElementBehaviorsTest.java | 42 + .../moddev/mc/lib/gui/ElementSyncTest.java | 18 + .../moddev/mc/lib/gui/RecipeSelectorTest.java | 73 ++ .../mc/lib/machine/MachineColorsTest.java | 42 + .../lib/machine/MachineDefinitionsTest.java | 316 +++++ .../lib/machine/MachineInlineColorsTest.java | 182 +++ .../multiblock/StructureTransformTest.java | 40 + .../presentation/EmissiveMaskTest.java | 44 + .../presentation/MachineVisualStateTest.java | 131 ++ .../presentation/VisualConditionTest.java | 53 + .../presentation/VisualVariablesTest.java | 47 + .../lib/machine/recipe/InputClaimsTest.java | 142 +- 129 files changed, 11412 insertions(+), 3483 deletions(-) create mode 100644 docs/local-development.md create mode 100644 docs/multiblocks.md create mode 100644 src/companion/java/zone/moddev/mc/companion/MultiblockDemo.java create mode 100644 src/companion/java/zone/moddev/mc/companion/MultiblockDemoEntity.java create mode 100644 src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_demo.json create mode 100644 src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_part.json create mode 100644 src/companion/resources/assets/mmdlib_companion/models/block/multiblock_demo.json create mode 100644 src/companion/resources/assets/mmdlib_companion/models/block/multiblock_part.json create mode 100644 src/companion/resources/assets/mmdlib_companion/models/item/multiblock_demo.json create mode 100644 src/companion/resources/mmdlib/screen/multiblock_demo.xml create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/DisabledFaceTests.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/MultiblockClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/capabilities/FaceMask.java create mode 100644 src/main/java/zone/moddev/mc/lib/api/util/ChangeSignal.java create mode 100644 src/main/java/zone/moddev/mc/lib/client/jei/JeiGhostRenderer.java create mode 100644 src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayout.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/client/RecipeSelectorBehavior.java create mode 100644 src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/definition/MachineColors.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/inventory/GhostItemGroup.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/FixedLayout.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/LinkedMember.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/MemberLink.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockController.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockDefinition.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/ResolvedStructure.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMember.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMembership.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructurePort.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureRequirement.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureResolver.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureTransform.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineVisualState.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockItem.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockMembers.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPartEntity.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPlacement.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPorts.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockResources.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/client/ConditionalMachineModel.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/client/FaceTextureModel.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/client/MaskedFaceGeometry.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/BuiltinVisualVariables.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalFaceTexture.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalModelPart.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/EmissiveMask.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/VisualCondition.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariableProvider.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariables.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReusableInputSmoke.java create mode 100644 src/test/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayoutTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/MachineColorsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/MachineInlineColorsTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/multiblock/StructureTransformTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/presentation/EmissiveMaskTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/presentation/MachineVisualStateTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/presentation/VisualConditionTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/presentation/VisualVariablesTest.java diff --git a/build.gradle b/build.gradle index 49826ad0..535f2e3d 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,14 @@ apply from: 'gradle/import-blocks.gradle' def env = System.getenv() def tag = providers.environmentVariable('TAG') -version = tag.orElse("${mod_version}-development").get() +def localMavenRepo = providers.gradleProperty('mmdlibLocalRepo').filter { !it.isBlank() } +def useLocalMaven = providers.gradleProperty('mmdlibUseLocal').map { it.toBoolean() } + .getOrElse(true) && localMavenRepo.isPresent() +def developmentVersion = useLocalMaven + ? providers.gradleProperty('mmdlibLocalVersion').getOrElse("${mod_version}-development") + : "${mod_version}-development" +// Release tags always win over machine-local development settings. +version = tag.orElse(developmentVersion).get() group = mod_group base { @@ -172,10 +179,11 @@ publishing { } } } - // Local inspection: `./gradlew publishAllPublicationsToLocalRepository` writes to mcmodsrepo/. + // Shared dev repository from ~/.gradle/gradle.properties; defaults to mcmodsrepo/. + // Publish only locally with: ./gradlew publishAllPublicationsToLocalRepository maven { name = 'local' - url = layout.projectDirectory.dir('mcmodsrepo') + url = useLocalMaven ? uri(localMavenRepo.get()) : layout.projectDirectory.dir('mcmodsrepo') } } } diff --git a/docs/capabilities.md b/docs/capabilities.md index 833bfd07..04a55dd8 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -204,3 +204,22 @@ is checked every tick, keep a NeoForge `BlockCapabilityCache` instead. - The runtime smoke test covers roles, filters, limits, faces, out-of-range indices, transfer, NBT round trips, int clamping, and the face mapping. Real cables and pipes still need testing in game. + +### Physically unavailable faces + +Use `new SideConfig(IoMode.BOTH).disabledFaces(Set.of(RelativeFace.RIGHT))` for a face that +must never expose this resource. All faces are available by default. Restrictions are relative +to the controller front and rotate with it; RIGHT means the viewer's right while looking at +the front (west for a north-facing controller). + +This is machine policy, distinct from a player's Disabled mode. `faceAvailable(face)` reports +it; `get`, `set`, `cycle`, and `copyFrom` enforce it. Restore decoded modes into the existing +policy-bearing instance. The codec saves modes only, not restrictions. Changing the mask +notifies the owner once, and re-enabling a face leaves its mode Disabled. AutomationInventory +also advances its view revision and invalidates capability caches on side changes. + +Retained handlers must still consult the current policy: use `items.view(slot -> +sides.mode(facing(), side))` and `energy.view(() -> sides.mode(facing(), side))`, intersected +with each resource's supported direction. Passing a mode value creates a fixed view and cannot +track future changes. Active transfers must consult the same policy. Null-side/internal access +remains host-owned; a machine may refuse it independently of its six physical faces. diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 171d44a0..ffdce750 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -752,7 +752,8 @@ submission path, including pending-request handling. The server dispatcher check request order, reach and visibility before decoding. The ghost action delegates to the inventory component; `InventoryElement.Session` resolves the named inventory and rejects inactive, unregistered or non-ghost slots. The old `ScreenNetwork.Ghost` packet and `MachineMenu.ghost` -entry point are removed. Core screen protocol 20 requires matching client/server versions. +entry point are removed. Core screen protocol 21 requires matching client/server versions; +menu opening now carries a host-validated physical interaction anchor for multiblocks. Bind the block's `block-right-click` event to `mmdlib:open_menu`. This handler first looks for an XML screen matching the clicked block entity's type; otherwise it uses the block's own vanilla @@ -956,7 +957,7 @@ payloads, codecs and handlers. `ScreenRuntime` only installs the registry; the g network has no list of feature networks. NeoForge continues to negotiate versions and dispatch payloads on its normal handler thread. Handlers remain responsible for menu/session identity, authorization, revisions and rate limits. Each feature declares its own version; the core screen -protocol is 19, and automation, redstone and container drawers remain at version 16. +protocol is 19, and automation and redstone drawers use version 17 (unavailable-face masks). ### Registered menu components and native slots @@ -2562,3 +2563,23 @@ None. - **Meter extras:** colour zones (VU style) and peak hold (EQ style). Left out to keep the first version simple. + +### Unavailable resource and redstone faces + +`SideConfig.disabledFaces(Set)` and `RedstoneControl.disabledFaces(Set)` +set machine-owned restrictions independently. Apply both when a physical obstruction blocks +both resource access and redstone. The side drawer, inline ``, and redstone drawer +retain all six tiles but show unavailable faces as a centered gray X at half the tile size, +without neighbor models or names. They explain the restriction on hover, +and refuse edits. Redstone selection moves to an available face; all-unavailable configurations +hide editing controls. Server validation also rejects stale or forged edits to blocked faces. + +Redstone restrictions prevent sampling, interval matching (including Outside), output emission, +and dust connections. Modes normalize to Ignore, including after loading or legacy migration; +re-enabled faces remain Ignore. Disk saves contain player settings only. Hosts outside BaseMachine +must use `saveClient`/`loadClient` for world updates so client dust geometry receives policy. +BaseMachine does this automatically. Normal disk persistence still uses `save`/`load`. + +Automation and redstone drawer protocols are version 17 with bounded unavailable-face masks. +Inline controls synchronize a separate `mmdlib:side_unavailable` field. Existing machines default +to all faces available and need no policy declaration. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 00000000..fdb4aa08 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,40 @@ +# Using MMDLib from a local Maven repository + +MMDLib can publish the working checkout to a shared folder without pushing or +waiting for CI. Configure the folder and development version in the Gradle user +home `gradle.properties` (normally `%USERPROFILE%\.gradle\gradle.properties` on +Windows, or `$GRADLE_USER_HOME/gradle.properties` when customized): + +```properties +mmdlibLocalRepo=Z:/maven +mmdlibLocalVersion=2.0.0.121012-development +``` + +Publish locally with: + +```powershell +.\gradlew.bat publishAllPublicationsToLocalRepository +``` + +This task publishes the main jar, sources, Javadoc and Maven metadata. It does not +publish to the remote repository. Without `mmdlibLocalRepo`, the local destination +remains `mcmodsrepo/` in this checkout. Without a local version override, the +development version remains `${mod_version}-development`. The `TAG` environment +variable always takes precedence for release builds. + +Consumer projects can read the same two properties to select the local repository +and dependency `zone.moddev.mc.lib:mmdlib:`. When local settings +are absent, use `https://maven.moddev.zone/releases` and the consumer's pinned release version. +Declare the selected repository as exclusive for the MMDLib module so a missing +local artifact cannot silently fall back to a remote build. Mark the local +dependency as changing and set its changing-module cache lifetime to zero, since +successive local publishes reuse the same version. + +Republish after editing MMDLib, then rebuild or restart the consumer's development +game. The source checkout can contain uncommitted changes. A Maven dependency does +not automatically rebuild the source checkout. + +`-PmmdlibUseLocal=false` disables the local overrides in MMDLib; consumers using +this convention can use the same flag to verify their pinned remote dependency. +Keep machine-local settings outside project repositories. Release tags and remote +publishing credentials continue to come from CI. diff --git a/docs/machine-companion.md b/docs/machine-companion.md index eb55f8cb..f9b72368 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -1,6 +1,6 @@ # Machine companion -The optional `mmdlib_companion` mod contains six playable machine examples. +The optional `mmdlib_companion` mod contains playable machine examples, including a multiblock fixture. It uses Minecraft 1.21.1, NeoForge 21.1.247 and this branch's MMDLib. Its classes and resources live in `src/companion` and are packaged separately from the library. @@ -39,6 +39,13 @@ those mods' login payloads; real client smoke runs include them. | `logistics` | Seven independent resource groups for testing automation, filters and side configuration; no recipe processing | | `creative_power` | Refills its buffer and supplies up to 1,000 FE per enabled adjacent face per tick | | `simple_crusher` | XML-only definition with inherited power, generated screen contents, and tag-generated ore-to-ingot recipes; in the **Data-defined Machines** tab | +| `multiblock_demo` | One item places a controller and two linked parts; the far part exposes item/fluid/energy ports; breaking a member dismantles the machine | + +The **Multiblock Test Bench** is in the Machines tab. It occupies the controller position and two +positions east of it when facing north, and rotates with placement. Keep the block above the middle +part clear; obstructing it suspends its active-tick counter and resource ports. Clicking any part +opens the controller screen. Ordinary survival breaking drops one machine item and its item +contents. See [multiblocks](multiblocks.md) for the reusable API and registration example. The crusher recipes take 100 ticks and 2,000 FE per cycle: cobblestone becomes gravel, gravel becomes sand, and copper/iron/gold ore becomes two of the corresponding raw material. The converter takes diff --git a/docs/mod-author-guide.md b/docs/mod-author-guide.md index 2bd4e8ce..db32ef15 100644 --- a/docs/mod-author-guide.md +++ b/docs/mod-author-guide.md @@ -3,6 +3,9 @@ This guide targets this checkout of MMDLib on **NeoForge 1.21.1 / Java 21**. The example material is `example:orichalcum`. Substitute your mod ID for `example`. +For building a consuming mod against your working checkout without waiting for CI, +see [local Maven development](local-development.md). + ## What “all features” currently means A material JSON file describes item, block, projectile, block-entity, and fluid @@ -20,6 +23,9 @@ including inherited storage, generated screens and dynamic recipes. More special machines and custom world entities use their dedicated typed factory APIs. The manual wiring below remains an alternative; do not queue objects twice. +Java machines spanning several blocks can use [multiblock placement and lifecycle](multiblocks.md), +with one controller, linked physical parts and guarded resource ports. + ## 1. Lay out the data A useful layout in the consuming mod is: diff --git a/docs/multiblocks.md b/docs/multiblocks.md new file mode 100644 index 00000000..c2e65e86 --- /dev/null +++ b/docs/multiblocks.md @@ -0,0 +1,123 @@ +# Java multiblock machines + +MMDLib provides whole-machine placement for fixed Java-defined structures. One controller owns storage and processing; linked physical parts expose interactions and optional resource ports. The companion's **Multiblock Test Bench** is a complete registration example in `MultiblockDemo` and `MultiblockDemoEntity`. + +The common structure model is in `zone.moddev.mc.lib.machine.multiblock`. NeoForge placement, blocks, linked block entities and capability adapters live in `machine.neoforge`. These APIs do not depend on the XML machine-definition runtime. Screens can still use ordinary MMDLib XML. + +## Define the structure + +Construct one `MultiblockDefinition` after your block registrations are available and return it from your placement item's supplier and controller factory. It declares: + +- A namespaced ID and positive version. Changing the saved structure contract requires an explicit version/migration decision. +- A `StructureResolver`, initially a `FixedLayout`. +- Finite limits: at most 4,096 members and offsets bounded by 128 blocks on each axis. Choose much smaller limits for ordinary machines. +- A `Dismantling` policy: `REMOVE_OWNED` for packaged machines or `LEAVE_BLOCKS` for already-built structures. +- Named requirement predicates and item/fluid/energy port declarations. + +For example, the fixed layout can be constructed as follows, with `controllerBlock` and `beltBlock` already registered: + +```java +var layout = new FixedLayout( + List.of( + new StructureMember("controller", "controller", BlockPos.ZERO, + controllerBlock.defaultBlockState()), + new StructureMember("belt", "belt", new BlockPos(1, 0, 0), + beltBlock.defaultBlockState())), + List.of(new StructureRequirement("clearance", new BlockPos(1, 1, 0), true))); +``` + +The controller is the sole member with role `controller`, at offset zero. Member keys and positions must be unique. States and offsets are authored facing north; `StructureTransform` rotates positions and states consistently for four horizontal orientations. Custom blocks must implement state rotation; `MultiblockBlock` supplies horizontal facing and rotation. + +Required space is separate from physical membership. A rule is a mod-owned `(Level, BlockPos) -> boolean` predicate. Set `continuous` for conditions that suspend operation while obstructed; otherwise a requirement is checked only at formation. Clearance rules for an animal gym should allow the intended animal while refusing obstructing blocks. Requirements never become removable dummy blocks. + +## Register the blocks and item + +Register a controller block/type, one or more physical part blocks/types and one `MultiblockItem`. No independent part item or part loot is needed for packaged machines. + +`MultiblockBlock` is an optional extensible block base that supplies controller ticking, part reconciliation, click routing, horizontal orientation and removal hooks. Register parts with `MultiblockPartEntity`; it saves the controller location, formation UUID and member key without owning storage. Its scheduled load reconciliation handles unavailable controllers and stale links. + +Use `new MultiblockBlock(properties, typeSupplier, false)` for non-ticking part blocks. The two-argument constructor enables the controller's `BaseMachine` ticker. Part load reconciliation uses scheduled block ticks rather than a permanent block-entity ticker. + +Create the item with: + +```java +new MultiblockItem(new Item.Properties(), MyMachine::definition) +``` + +Placement checks the full footprint, replacement rules, build/world bounds, player permissions, entity collision and requirement predicates. It posts NeoForge's multi-place event and rechecks the candidate before committing. Cancellation rolls back owned writes without consuming the item. A callback's unrelated replacement is preserved. The item consumes one unit on successful survival placement; creative placement consumes none. + +`MultiblockPlacement.place(context, definition)` is also available directly. It performs the server transaction and returns a result/diagnostic; it does **not** consume an item. Do not invoke both it and the item's `useOn` for the same placement. + +## Compose the controller + +Create a `MultiblockInstance` in your `BaseMachine` or `BasePoweredMachine` subclass and override `multiblock()` to return it: + +```java +structure = new MultiblockInstance(this, MyMachine.definition(), + MultiblockMembers.INSTANCE, new MultiblockInstance.Listener() { + @Override + public void changed(MultiblockInstance.Status previous, + MultiblockInstance.Status current, String reason) { + // Stop/release temporary world interactions when suspended or invalidated. + } + + @Override + public void dismantling(MultiblockInstance.Cause cause) { + // Cancel features, recover/drop owned contents and apply the machine's loot policy. + } + }); +``` + +`BaseMachine` maintains the instance before processing, persists its manifest and synchronizes presentation state. While unavailable it suspends features, skips custom before/after processing hooks and blocks lifecycle automation transfers. Refund settlement still runs because already-owed resources must be returned. Other code that pushes resources outside these hooks must check `structureReady()`. + +The statuses are `UNFORMED`, `READY`, `SUSPENDED` and `DISASSEMBLING`. Ready means structurally available, not powered or actively processing. An unavailable member chunk suspends the instance without loading that chunk or deleting members. A known missing/wrong member dismantles according to policy. Reloaded manifests are suspended until verified; incompatible manifests remain inactive and are retained alongside storage. + +The current fixed-layout runtime validates its bounded membership on readiness checks, including retained port access. It does not rediscover structure shape. Large variable structures will need indexed invalidation/bounded validation scheduling when their resolver support is added. + +Other block entities can implement `MultiblockController` and compose the instance directly. They must call its tick/save/load/client-state hooks and apply processing/transfer gates themselves. Only `BaseMachine` supplies that wiring automatically. + +## Dismantling and loot + +The library revokes processing and port access before invoking `dismantling`. The consumer owns feature cancellation, resource recovery and machine loot. Give physical members no independent machine-item loot; otherwise ordinary block destruction could duplicate the callback's item drop. + +The companion drops stored items once and awards one machine item only for an ordinary survival break. Creative destruction awards no machine item. Generic replacement/explosion/removal paths use recovery-only cleanup; a custom block can supply a more specific policy. If the original break context is unavailable after chunk loading, recovery does not invent survival loot. + +`REMOVE_OWNED` removes only matching members with reciprocal ownership, with the controller last. Reentrant removal cannot repeat the dismantling callback. `LEAVE_BLOCKS` clears links and leaves blocks intact; its callback should retain storage and settle reservations without dropping the machine. Storage reduction during a future reformation must use recovery rather than truncation. + +Handle world interactions such as animal restraint on controller/chunk unload as well as lifecycle transitions. An unloaded controller cannot keep running a cleanup tick. Store persistent animal fitness on the animal, not in a part block. + +## Expose guarded ports + +Declare `StructurePort` entries by member key, stable resource name, kind, local faces, unsided permission and input/output permission. Multiple bindings for the same member/kind/face are rejected. Register capabilities on your controller/part types using: + +```java +MultiblockPorts.register(event, controllerType, partType); +``` + +The controller implements `MultiblockResources`, returning its normal filtered resource views from `multiblockItems`, `multiblockFluids` and `multiblockEnergy`. The face argument is in world coordinates. Resolve side configuration against the controller's orientation. Return null for unknown resources. Port permissions only narrow those views; they do not bypass roles, filters, reservations or transfer rates. + +The wrappers bind to a controller instance, formation UUID, member link and revision. They check live readiness on reads and writes. A cached wrapper becomes inert while suspended or after teardown and cannot attach to a replacement controller or a new formation. Capability availability changes also invalidate NeoForge caches. Unsided access is refused unless explicitly enabled in the declaration. + +Use these adapters for controller ports too; separately registering raw handlers can bypass structure availability. Resource pushing outside the normal machine lifecycle must use the same gate. Remote redstone forwarding is not included. + +## Route menus and rendering + +`MultiblockBlock` routes an ordinary click through the verified member to the controller. Override item interactions for buckets, tools or other behavior. Custom blocks can call `ScreenRuntime.open(player, controller, clickedMember)`. + +`ScreenHost.screenInteractionAnchor` and `screenInteractionIdentity` authorize physical anchors. `BaseMachine` supplies reciprocal member checks and a formation identity for remote anchors. `MachineMenu` retains controller identity, level and player-reach checks throughout the session. Arbitrary nearby positions cannot act as anchors. Diagnostic menus at the controller may remain available while inactive; machine actions must still check readiness. + +Menu protocol version **21** includes the opening anchor, so client and server need matching MMDLib versions. Existing single-block hosts default to their controller position. + +Use `StructureTransform.bounds` for controller rendering bounds when rendering beyond one block. Shapes and models remain mod-owned. The companion uses per-member static models and a status/resource screen; the library does not render animals or animate a treadmill. + +## Extension points for later assembly + +`StructureResolver` returns a `ResolvedStructure` containing actual members, requirements and derived measurements. It also validates saved manifests through `accepts`. Its world view refuses reads from unloaded chunks. The fixed-layout adapter produces a candidate without requiring the world to contain the machine already. + +`MultiblockInstance.form(candidate, facing)` activates already-existing members independently of placement or item consumption. The caller must authorize any player action before invoking it. `StructureMembership` separates claims from storage, allowing a future persistent casing index instead of a block entity at every position. `LEAVE_BLOCKS` supports non-destructive invalidation. Server tests exercise this path with existing linked members. + +Variable-size discovery, passive vanilla-casing ownership, controller activation UX, resizing, in-place rotation/movement and cross-dimension structures are not shipped by this phase. Their implementation can reuse the committed-instance, port and lifecycle contracts. + +## Verify the example + +`./gradlew check` runs unit and server GameTests, including placement rollback, chunk suspension, save recovery, retained capability guards, member menus and existing-member formation. `./gradlew runMultiblockClientSmoke` runs only the focused multiblock client check and saves a screenshot under `build/multiblock-client-smoke/screenshots/`. The runner verifies real survival placement, part synchronization, remote menu inventory and teardown, and fails without its explicit success marker. diff --git a/docs/xml-machines.md b/docs/xml-machines.md index 2fd9fc9a..5724beef 100644 --- a/docs/xml-machines.md +++ b/docs/xml-machines.md @@ -61,6 +61,296 @@ table in a low-priority resource pack. Authored resources can override these defaults. Crafting recipes remain ordinary files under `data//recipe/`. The companion's SimpleCrusher is in the **Data-defined Machines** creative tab. +## Ghost inventory inputs + +Declare a sample-only inventory alongside ordinary item storage: + +```xml + + + + +``` + +`` also works; `count` +is optional on the singular form. Ghost inventories require `role="input"`, have +one-item samples rather than stack limits, and are included in the 54-slot limit. +`default-item` is optional; without it the initial sample is empty. Ordinary slots +retain their default `type="item"` behavior. + +Recipes use normal item ingredients; the storage type supplies the non-consuming +semantics: + +```json +"inputs": { + "input": [{"items": {"item": "minecraft:stone"}, "count": 1}], + "water": [{"fluids": "minecraft:water", "amount": 100}], + "energy": [{"energy": 640}] +} +``` + +The input resource-group generator emits ordinary `` elements under +``. Existing ghost clicks and JEI drops edit the sample. +When a machine has one ghost item ingredient and no real item inputs, JEI's **+** +button selects that recipe's sample through the same server-validated slot action. +Open the machine before viewing its recipes. Selection needs neither a sample in +the player's inventory nor the recipe's fluids or energy; it only changes the ghost. +An explicit screen can use ``. +No picker, recipe ID selection or special output rule is required. `filter-inputs` +continues to list physical admission-filter groups, excluding ghost inventories. + +Ghost samples are persisted and synchronized separately from real items. They do +not allocate physical capability slots or automation groups, and never enter drops +or recovery capsules. Recipes may match them but cannot produce into them. Samples +are checked when starting a cycle and excluded from its reservations and consumption. +Changing or clearing a sample affects the next cycle, including when the old cycle +is paused and later resumes. Counts and tags use the usual item ingredient matcher. +As with other input recipes, overlapping matches follow normal recipe lookup rules. + +## Explicit recipe selection + +For machines that specifically need a saved recipe ID, the separate optional +selector below remains available. Ghost inventory inputs do not use it. + +Processors normally choose a matching recipe automatically. Opt into a saved, +explicit selection with `selectable="true"`: + +```xml + +``` + +Place `` inside the machine's screen fill. +It occupies one 18 × 18 ghost item slot. Clicking opens a compact strip of recipe icons for +that processor's type, including unavailable recipes with requirements and costs. +Clicking with a carried item or dropping a JEI item selects recipes producing that +item without moving the item. Multiple matches open a filtered chooser; the exact +recipe ID is saved. Right-click clears the selection; Escape or clicking outside closes +the icon strip. Extra recipes use page arrows or scrolling. Costs, missing requirements, +and queued-work details are in the tooltip, with no permanent panel or caption. +The selector uses the existing element-action authorization and bounded synchronization transport. + +`default-recipe` is optional and requires `selectable="true"`. Without a default, +the processor starts with no selection. Old saves use the configured default; +explicitly cleared selections stay cleared after reload. Existing automatic +processors remain automatic. Java processors opt in through +`processor.selectable(defaultRecipe)` before loading state and change selection +with `processor.selectRecipe(level, id)` (null clears it). + +Changes affect the next cycle, including when a cycle is paused or power-starved. +The old cycle keeps its original output, fluid costs and paid energy progress. +The selector displays the chosen output and any previous recipe still finishing. +The processor does not fall back to another recipe when resources are missing. +Only IDs belonging to its recipe type are accepted. If `/reload` removes the +selection, no new cycle starts; a reserved cycle can finish from its saved plan. +Recipe catalogs are indexed once per recipe-manager snapshot. Only visible page +entries and selected/active details are matched for the open menu, normally once +per second or after a selection/status change; there is no per-tick catalog scan. + +Costs remain ordinary recipe JSON: energy inputs are total FE per cycle, `time` +is processing ticks, and fluid inputs are required amounts. Returning the same +fluid amount to the same tank makes it reusable; omitting that return consumes it. +The selector summarizes required and consumed amounts separately for single-fluid +inputs with deterministic returns. Selection changes invalidate failed-search +backoff immediately. + +## Appearance presets + +`preset="cube-front"` generates an orientable cube from `front`, `side` and `top` +textures. The optional `bottom` texture defaults to `side`. It supports both `part` +and `face-texture` children, including emissive masks on switched textures. + +`preset="model"` uses an authored block model as the always-visible base and supports +only `part` children: + +```xml + + + + +``` + +Here `example:block/mixer_casing` refers to +`assets/example/models/block/mixer_casing.json`. Its geometry, textures and display +transforms come from that JSON. Parts are optional; a self-closing appearance with +just `preset="model"` and `model="..."` also works. This preset rejects `face-texture` +children and the cube texture attributes (`front`, `side`, `top`, `bottom`). Author +the parts' materials in their own model JSON. Both presets use the same variable +conditions and change notifications. Model geometry does not change collision bounds. + +When an inherited appearance changes preset, the old base attributes are discarded; +child rules are retained and validated against the new preset. Inherited face-texture +rules therefore prevent switching to `model`. Within the same preset, attributes and +parts continue to merge normally by ID. The authored base must have a different resource +ID from the machine's generated `block/` and `block/_base` models. + +## Conditional appearance + +Machine components expose typed presentation variables. An appearance may contain up to 64 +conditional parts and face-texture rules in total. Each part is an ordinary block model resource: + +```xml + + + + +``` + +### Face texture switching + +Use `face-texture` to replace a base-model face's texture without adding geometry: + +```xml + + + + + +``` + +Faces are `front`, `back`, `left`, `right`, `top`, and `bottom`, relative to the machine. +Left and right are as seen by someone looking at its front, matching side configuration. +Rules are evaluated in declaration order; the first matching rule **per face** wins. +If none matches, the existing base texture remains. Stable rule IDs allow inherited +definitions to override a rule in place without changing its priority. + +Textures are block-atlas resources, normally `assets//textures/block/*.png`. +Without a mask, the switch preserves geometry, UV layout, tint, shading, emissive values +and render layer. Use the same material type as the base; a texture swap does not change +a solid material into a transparent one. For authored multi-element base models, all quads pointing in +the selected local direction receive the replacement texture. Conditional child parts are +unaffected, so an emissive indicator can coexist with a switched front texture. +Selections are computed from immutable snapshots, and retextured quads use a bounded cache. +World facing rotations apply to both the geometry and texture rules. Item models retain +the base appearance. + +### Emissive masks + +An optional `emissive-mask` on a face-texture rule selects the glowing pixels: + +```xml + +``` + +Use a static black-and-white PNG aligned with the switched texture. White pixels use that +texture's color at full brightness, without directional shading or ambient occlusion. Black +or transparent pixels keep the base face's normal lighting. The mask is binary: alpha and +brightness must both be at least 128 to select a pixel; gray is not a brightness gradient. +The mask and switched texture are selected together by the rule's `when` condition. To vary +only the glow, use the same color texture in masked and unmasked rules with different conditions. + +Masks are read and partitioned once during model baking. The renderer caches the resulting +lit/unlit geometry; the regions share edges rather than overlaying a second face. There is no +per-tick pixel scan, and no additional overlay model is necessary. The mask must be a single +frame, at most 256 by 256 pixels and at most 4096 merged regions. Source faces need rectangular, +non-degenerate UVs within the texture; ordinary cube-front models and their facing rotations +meet this requirement. Masked faces keep the base render layer and do not emit world light. + +### Variables and conditions + +Use the actual processor, item-group, tank and energy IDs from the machine definition: + +| Component prefix | Variables | Meaning | +| --- | --- | --- | +| `processors.` | `running`, `paused` | Boolean activity/manual pause; completion ticks remain running. | +| `processors.` | `status` | Text: `idle`, `running`, `stalled`, `blocked`, `held`, `paused`. | +| `processors.` | `progress` | Fraction 0–1; retained during a pause, 1 on completion, 0 without a cycle. | +| `inventories.` | `count`, `fill`, `empty`, `full` | Total item count, mean slot fullness, and booleans. Fullness respects slot and item stack limits. | +| `tanks.` | `amount`, `capacity`, `fill`, `empty` | Fluid amounts in mB, fraction 0–1, and boolean emptiness. | +| `energy.` | `stored`, `capacity`, `fill` | FE amounts and fraction 0–1. | + +Conditions support booleans, numbers, quoted text, parentheses, `!`, `&&`, `||`, +`==`, `!=`, `<`, `<=`, `>`, `>=`, and arithmetic `+ - * /`. Escape `&` and `<` in XML. +Use `${tanks.input/0.fill}` for IDs containing slashes, hyphens or colons. Division by +zero produces zero. Conditions must return a boolean; unknown variables and type +mismatches fail at startup with the XML source location. Numbers use double precision, +so use fill ratios rather than exact comparisons of very large FE totals. + +Parts inherit the block's facing rotation and are additive. Use complementary conditions +to swap geometry. The part's model owns its textures, render type and emissive face data; +an illuminated strip can use NeoForge `neoforge_data` with `block_light: 15`, +`sky_light: 15`, `ambient_occlusion: false`, and `shade: false` on its element. +Keep overlay faces slightly offset to avoid z-fighting. Emissive faces do not emit world light. + +With parts or face-texture rules present, the generated `block/` model uses the +`mmdlib:conditional_machine` loader and references `block/_base` plus each part. +For `cube-front`, the generated base is a cube; for `model`, it inherits the authored +model named in XML. Override the **base** model to customize the casing; overriding the root model bypasses +the generated composition. `base` is a reserved part ID. Items display only the base. +Machines without conditional appearance keep their existing generated models and do not sample variables. + +Only referenced values are subscribed and sent to tracking players, including their initial +chunk snapshot, independently of menus. Component change notifications invalidate cached values +and schedule one update for the next server tick; multiple notifications coalesce. There is no +repeating appearance tick or getter polling. Reading a clean snapshot, including one for a newly +tracking player, reuses cached values without consuming any pending broadcast. Client snapshots +are immutable; identical snapshots skip condition evaluation, and chunk geometry is invalidated +only when an appearance condition changes result. Processor activity and progress have separate +signals: a running-only indicator does no appearance work for progress increments or energy use. +Variables that actually change each tick can still produce updates each tick when referenced. +These are static conditions; transforms, interpolation and animations are not implemented. +Keep conditions in the machine XML: changing predicates only in a resource-pack model would +not change the server's subscriptions or the client's invalidation rules. + +### Java components + +Implement `VisualVariableProvider` or use a component adapter. Declare each local name, +its typed default, and a server-side getter, then compose providers under stable prefixes: + +```java +var heaterChanges = new ChangeSignal(); +VisualVariableProvider heater = vars -> vars.observedBy(heaterChanges) + .add("temperature", 0.0, this::temperature) + .add("ready", false, this::ready); +var variables = new VisualVariables.Builder() + .component("custom.heater", heater) + .component("processors.main", processor) + .build(); +var visuals = new MachineVisualState(variables, + List.of("$custom.heater.ready && $processors.main.running")); +// After a component mutation (not from a polling loop): +heaterChanges.changed(); +``` + +`RecipeProcessor` implements the provider contract. `BuiltinVisualVariables` supplies +item, tank and energy adapters. This is an instance-level composition API, with no global +provider registry and no renderer-specific component type switches. Declarations are fixed +for the instance; duplicate names, non-finite numbers and changes in value type are rejected. +The current bounds are 256 variables, 256 characters per name/text value, and 2048 characters +per condition. Custom providers currently attach through Java; XML assembles the built-ins. + +`ChangeSignal` lives in `api.util`; component listeners are separate from storage persistence +callbacks. A getter declaration without `observedBy` must be explicitly invalidated with +`variables.changed("fully.qualified.name")` after changes. Getters are read-only. Component +schemas are stable for the lifetime of an instance; reconnecting a removed owner resamples them. + +For a Java machine, call `visuals.start(scheduleOnce)` when the server-side owner loads. The +scheduler must defer a callback that calls `visuals.flush(this)` until mutations have settled; +XML machines use a scheduled block tick with a one-tick delay. Call `visuals.stop()` when the +owner is removed/unloaded to detach subscriptions. No repeating tick hook is needed. Write +`visuals.write()` into `saveClientData`, apply it with `visuals.read(tag, this)` in +`loadClientData`, and expose `visuals.modelData()` from `getModelData`. Do not persist these +snapshots as inventory data. A hand-authored `mmdlib:conditional_machine` model supplies +`base` (a model object), `variables` (name-to-default values), and optional `parts` (objects with +`id`, `when`, and `model`). Optional `face_textures` entries contain `face`, `when` and +`texture`, and optionally `emissive_mask`; the texture and mask names refer to variables in +the root model's `textures` object, without `#`. +Its declarations and conditions must match the Java subscriptions. +Getters are never invoked on the rendering thread. + ## Base machine and storage The generic [`base_machine.xml`](../src/main/resources/mmdlib/machine/base_machine.xml) @@ -71,10 +361,10 @@ tank fills. It declares no storage. The powered base extends it: - + - + @@ -136,6 +426,93 @@ Displaced resources use `mmdlib:machine_recovery_capsule`; use a capsule on an X machine to restore compatible items, fluids and energy. Overflow and unknown resource entries remain in the capsule. +### Inline colors and the default palette + +Automatic groups accept `color` directly on ``, ``, `` and +``. Use a palette name or a six-digit `#RRGGBB` color; both resolve to the +same RGB value used by automation controls, resource highlights and generated +inventory outlines. Colors are presentation only: a name never changes IO rules +or fluid filters. These names apply to **machine XML**; handwritten screen XML +keeps its existing color syntax. + +```xml + + + + + + + + + + +``` + +This example inherits the energy ID and zero output rate from +`mmdlib:base_powered_machine`. No `` section is needed: each declaration +already supplies its group's ID, membership, role and color. + +The default palette uses blue/violet/rose for item inputs, cyan/green/gold for +item outputs, blue/amber/violet for fluids, and coral for power: + +| Name | RGB | Intended use | +| --- | --- | --- | +| `input.primary` | `#568BD8` | Main item input | +| `input.secondary` | `#A184CE` | Additional item input | +| `input.tertiary` | `#CF84AA` | Third item input | +| `output.primary` | `#4DC6D8` | Main product | +| `output.secondary` | `#83B967` | Secondary product | +| `output.tertiary` | `#D9BB62` | Further output/byproduct | +| `fluid.primary` | `#508EE0` | First fluid group; useful for water/coolant | +| `fluid.secondary` | `#EE963C` | Second fluid group; useful for lava/fuel | +| `fluid.tertiary` | `#9C83CF` | Third fluid/reagent group | +| `energy` | `#D85B5B` | Power | +| `storage` | `#8C9EAC` | Bidirectional item buffer | +| `manual` | `#AAA0B7` | Manual-only items | + +Names are case-sensitive. Any name may be used on any group, and hex digits may +be upper- or lowercase. Unknown names and malformed hex fail at startup with the +authored file, line and value, including errors in inherited definitions. + +With `color` omitted, input and output item groups advance independently through +their three variants in resolved declaration order and repeat after the third. +Fluid groups use their own sequence, irrespective of input/output role. Explicitly +colored groups still occupy their position in that sequence. A `` +or `` is one group and receives one color, not one per member. +Bidirectional and manual item groups use `storage` and `manual` respectively. +Explicit names pin a color even when group order changes. Fluid identity does not +select colors automatically. + +An explicit `` accepts the same names and overrides an inline +color; omitting its color preserves the inline/default choice. Inheritance merges +inline attributes normally. Explicit groups remain useful for grouping standalone +`` entries or overriding automation mode. A standalone `` has no +automatic group, so its color belongs on that explicit group. + +For a tank that players fill through its screen gauge but automation must not +access or configure, use `role="manual" automation="false"`: + +```xml + +``` + +`automation` defaults to `true` on `` and ``. Setting it to `false` +requires `role="manual"`, removes the automation group and side-configuration +highlight, and keeps a recipe group with the declared ID. Screen gauges, saved +contents, fluid filters and recipe reservations still work. Use `"consumed": false` +on that recipe input when the fluid must remain in place. Manual filling uses the +screen gauge; pipes and bucket interactions on the block cannot access these tanks. + +The library base declares its energy color inline, so a child can override it with +``. It retains the uncolored energy group declaration for +compatibility with existing partial group overrides. Existing explicit group hex colors remain supported. +Uncolored machines adopt this palette; storage identities and capacities do not +change. Java consumers can use `MachineColors.parse(...)` and the immutable +`MachineColors.palette()` map from `zone.moddev.mc.lib.machine.definition`. + ## Simple Lava Furnace [`simple_lava_furnace.xml`](../src/companion/resources/mmdlib/machine/simple_lava_furnace.xml) @@ -410,6 +787,40 @@ uses the declared type's normal codec: } ``` +### Reusable recipe inputs + +Item and fluid inputs accept `"consumed": false`; omitted means `true`: + +```json +"inputs": { + "water": [{"fluids": "minecraft:water", "amount": 1000, "consumed": false}], + "catalyst": [{"items": {"item": "minecraft:diamond"}, "count": 1, "consumed": false}] +} +``` + +These are real resources: the full amount must be present and is reserved while +the cycle runs, preventing extraction or use by another cycle. Completion releases +the reservation without extracting the reusable amount. Do not duplicate it in +`outputs`. It still occupies storage space, including when products share the +same group. Consumed and reusable inputs in one group require separate amounts. +Reservations survive save/load and are released on pause as usual. JEI labels +these inputs **Not consumed**. Energy is spent per tick and rejects `consumed: false`. + +Ghost storage remains different: its sample only matches the recipe and represents +no real resource to reserve or consume, regardless of this flag. +JEI also labels ghost item inputs **Not consumed**, using the storage declarations. +For a recipe type shared by several XML machines, this automatic label applies only +to groups that are ghost inputs on every machine using that type. +Ghost samples and reusable items/fluids appear faded in a separate group before +the consumed ingredients: `[ghosts] : [inputs] → [outputs]`. The colon is omitted +when there are no consumed ingredients. Samples are display-only in JEI, while +real reusable resources are catalysts and remain available in usage lookups. +Fluid icons use a capacity rounded up to the next 1,000 mB: 250 mB fills one +quarter of a bucket, 100 mB one tenth, and 1,250 mB uses a 2,000 mB scale. This +applies to consumed inputs, reusable fluids and outputs; tooltips keep the exact amount. +Reusable-fluid tooltips explicitly say **Requires N mB** alongside **Not consumed**; +the ghost appearance does not remove or reduce that requirement. + ### Several recipes in one JSON file Use `mmdlib:machine_recipes` with a named `recipes` object in the usual recipe @@ -468,6 +879,6 @@ declarations must agree on name, generator class and properties. A definition referencing it with `declare="false"` must not supply a generator. Types registered only by Java are not currently resolved by this XML loader. -This delivery covers item processing with optional FE and fluids, and cube appearances. +This delivery covers item processing with optional FE and fluids, and cube or authored-model appearances. Multiple processors, world-interaction features and standalone loose content packs remain outside this format's implemented scope. diff --git a/scripts/companion.gradle b/scripts/companion.gradle index 757d939d..f2755cc7 100644 --- a/scripts/companion.gradle +++ b/scripts/companion.gradle @@ -96,6 +96,14 @@ neoForge { loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] systemProperty 'neoforge.enabledGameTestNamespaces', 'mmdlib_companion' } + multiblockClientSmoke { + client() + gameDirectory = layout.buildDirectory.dir('multiblock-client-smoke') + sourceSet = sourceSets.companionTest + loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] + systemProperty 'mmdlib.multiblockSmoke', 'true' + programArguments.addAll '--width', '1280', '--height', '960' + } simpleCrusherClientSmoke { client() gameDirectory = layout.buildDirectory.dir('simple-crusher-client-smoke') @@ -159,6 +167,19 @@ companionTests.configureEach { } tasks.named('check') { dependsOn companionTests, companionJar } +tasks.matching { it.name == 'runMultiblockClientSmoke' }.configureEach { + doFirst { + def smokeDir = layout.buildDirectory.dir('multiblock-client-smoke').get().asFile + smokeDir.mkdirs() + new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\npauseOnLostFocus:false\n' + } + doLast { + def log = layout.buildDirectory.file('multiblock-client-smoke/logs/latest.log').get().asFile + if (!log.exists() || !log.text.contains('MULTIBLOCK_CLIENT_PASS') || log.text.contains('MULTIBLOCK_CLIENT_FAIL')) + throw new GradleException('Multiblock client smoke failed; inspect ' + log) + } +} + tasks.matching { it.name == 'runCompanionClientSmoke' }.configureEach { doFirst { def smokeDir = layout.buildDirectory.dir('companion-client-smoke').get().asFile diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java index 01e9d15c..f2e4fbbe 100644 --- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java +++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java @@ -72,12 +72,14 @@ public final class CompanionMod { .displayItems( (parameters, output) -> { MACHINES.forEach(machine -> output.accept(machine.block.get())); + output.accept(MultiblockDemo.ITEM.get()); CompanionAddons.items().forEach(item -> output.accept(item.get())); }) .build()); } public CompanionMod(IEventBus bus) { + MultiblockDemo.register(bus); CompanionWidgets.register(); CompanionAddons.register(bus); BLOCKS.register(bus); diff --git a/src/companion/java/zone/moddev/mc/companion/MultiblockDemo.java b/src/companion/java/zone/moddev/mc/companion/MultiblockDemo.java new file mode 100644 index 00000000..7d9a9df6 --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/MultiblockDemo.java @@ -0,0 +1,96 @@ +package zone.moddev.mc.companion; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockBehaviour; + +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; + +import zone.moddev.mc.lib.machine.multiblock.FixedLayout; +import zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition; +import zone.moddev.mc.lib.machine.multiblock.StructureMember; +import zone.moddev.mc.lib.machine.multiblock.StructurePort; +import zone.moddev.mc.lib.machine.multiblock.StructureRequirement; +import zone.moddev.mc.lib.machine.neoforge.MultiblockBlock; +import zone.moddev.mc.lib.machine.neoforge.MultiblockItem; +import zone.moddev.mc.lib.machine.neoforge.MultiblockPartEntity; +import zone.moddev.mc.lib.machine.neoforge.MultiblockPorts; + +/** Playable three-position fixture for whole-machine placement and remote resource ports. */ +public final class MultiblockDemo { + private static final DeferredRegister BLOCKS = DeferredRegister.create(Registries.BLOCK, + CompanionMod.ID); + private static final DeferredRegister ITEMS = DeferredRegister.create(Registries.ITEM, + CompanionMod.ID); + private static final DeferredRegister> TYPES = DeferredRegister + .create(Registries.BLOCK_ENTITY_TYPE, CompanionMod.ID); + public static final DeferredHolder CONTROLLER = BLOCKS.register( + "multiblock_demo", + () -> new MultiblockBlock(BlockBehaviour.Properties.of().strength(3), + MultiblockDemo::controllerType)); + public static final DeferredHolder PART = BLOCKS.register( + "multiblock_part", + () -> new MultiblockBlock(BlockBehaviour.Properties.of().strength(3), + MultiblockDemo::partType, false)); + public static final DeferredHolder, BlockEntityType> CONTROLLER_TYPE = TYPES + .register("multiblock_demo", + () -> BlockEntityType.Builder.of(MultiblockDemoEntity::new, CONTROLLER.get()) + .build(null)); + public static final DeferredHolder, BlockEntityType> PART_TYPE = TYPES + .register("multiblock_part", + () -> BlockEntityType.Builder + .of((pos, state) -> new MultiblockPartEntity(partType(), pos, state), PART.get()) + .build(null)); + public static final DeferredHolder ITEM = ITEMS.register( + "multiblock_demo", + () -> new MultiblockItem(new Item.Properties(), MultiblockDemo::definition)); + private static MultiblockDefinition definition; + private MultiblockDemo() { + } + private static BlockEntityType controllerType() { + return CONTROLLER_TYPE.get(); + } + private static BlockEntityType partType() { + return PART_TYPE.get(); + } + public static void register(IEventBus bus) { + BLOCKS.register(bus); + ITEMS.register(bus); + TYPES.register(bus); + bus.addListener( + (net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent event) -> MultiblockPorts + .register(event, controllerType(), partType())); + } + public static MultiblockDefinition definition() { + if (definition == null) + definition = new MultiblockDefinition(CompanionMod.id("multiblock_demo"), 1, + new FixedLayout(List.of( + new StructureMember("controller", "controller", BlockPos.ZERO, + CONTROLLER.get().defaultBlockState()), + new StructureMember("belt", "belt", new BlockPos(1, 0, 0), + PART.get().defaultBlockState()), + new StructureMember("socket", "socket", new BlockPos(2, 0, 0), + PART.get().defaultBlockState())), + List.of(new StructureRequirement("clearance", new BlockPos(1, 1, 0), true))), + 16, 8, MultiblockDefinition.Dismantling.REMOVE_OWNED, + Map.of("clearance", (level, pos) -> level.getBlockState(pos).isAir()), + List.of( + new StructurePort("socket", "energy", StructurePort.Kind.ENERGY, + Set.of(Direction.values()), false, true, true), + new StructurePort("socket", "items", StructurePort.Kind.ITEMS, + Set.of(Direction.values()), false, true, true), + new StructurePort("socket", "fluid", StructurePort.Kind.FLUIDS, + Set.of(Direction.values()), false, true, true))); + return definition; + } +} diff --git a/src/companion/java/zone/moddev/mc/companion/MultiblockDemoEntity.java b/src/companion/java/zone/moddev/mc/companion/MultiblockDemoEntity.java new file mode 100644 index 00000000..aa65b2c6 --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/MultiblockDemoEntity.java @@ -0,0 +1,114 @@ +package zone.moddev.mc.companion; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; + +import net.neoforged.neoforge.energy.IEnergyStorage; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.items.IItemHandler; + +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; +import zone.moddev.mc.lib.machine.neoforge.BasePoweredMachine; +import zone.moddev.mc.lib.machine.neoforge.MultiblockMembers; +import zone.moddev.mc.lib.machine.neoforge.MultiblockResources; + +/** Minimal consumer; its counter proves that structure holds also gate custom machine hooks. */ +public final class MultiblockDemoEntity extends BasePoweredMachine implements MultiblockResources { + public final ItemStorage items = new ItemStorage(1).onChange(this::setChanged); + public final FluidStorage fluids = new FluidStorage().onChange(this::setChanged); + private final MultiblockInstance structure; + private int operations; + public MultiblockDemoEntity(BlockPos pos, BlockState state) { + this(pos, state, MultiblockDemo.definition()); + } + + public MultiblockDemoEntity(BlockPos pos, BlockState state, + zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition definition) { + super(MultiblockDemo.CONTROLLER_TYPE.get(), pos, state, 10000, 1000, 1000); + fluids.addTank(4000, Role.STORAGE, stack -> true); + structure = new MultiblockInstance(this, definition, + MultiblockMembers.INSTANCE, + new MultiblockInstance.Listener() { + @Override + public void dismantling(MultiblockInstance.Cause cause) { + features.reset(); + if (definition + .dismantling() == zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition.Dismantling.LEAVE_BLOCKS) + return; + items.dropAll(level, worldPosition, + payload -> recoverResources(level, worldPosition, payload)); + if (cause == MultiblockInstance.Cause.SURVIVAL) + net.minecraft.world.Containers.dropItemStack(level, + worldPosition.getX() + .5, + worldPosition.getY() + .5, worldPosition.getZ() + .5, + new ItemStack(MultiblockDemo.ITEM.get())); + } + }); + } + @Override + public MultiblockInstance multiblock() { + return structure; + } + @Override + public ItemStorage screenItems() { + return items; + } + @Override + public FluidStorage screenFluids() { + return fluids; + } + @Override + public boolean screenAutomationAddons() { + return false; + } + public net.minecraft.network.chat.Component getStructureStatus() { + return net.minecraft.network.chat.Component.literal(structure.status().name()); + } + public int getOperations() { + return operations; + } + @Override + protected void beforeMachineTick() { + operations++; + energy.insert(1, false); + setChanged(); + } + @Override + protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) { + tag.put("items", items.serializeNBT(registries)); + tag.put("fluids", fluids.serializeNBT(registries)); + tag.putInt("operations", operations); + } + @Override + protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) { + items.deserializeNBT(registries, tag.getCompound("items")); + fluids.deserializeNBT(registries, tag.getCompound("fluids")); + operations = tag.getInt("operations"); + } + @Override + protected void recoverResources(Level world, BlockPos pos, CompoundTag payload) { + net.minecraft.world.Containers.dropItemStack(world, pos.getX() + .5, pos.getY() + .5, + pos.getZ() + .5, RecoveryCapsuleItem.create(payload)); + } + @Override + public IItemHandler multiblockItems(String resource, Direction side) { + return resource.equals("items") ? items.view(IoMode.BOTH) : null; + } + @Override + public IFluidHandler multiblockFluids(String resource, Direction side) { + return resource.equals("fluid") ? fluids.view(IoMode.BOTH) : null; + } + @Override + public IEnergyStorage multiblockEnergy(String resource, Direction side) { + return resource.equals("energy") ? energy.view(IoMode.BOTH) : null; + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_demo.json b/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_demo.json new file mode 100644 index 00000000..e2265c9c --- /dev/null +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_demo.json @@ -0,0 +1,20 @@ +{ + "variants": { + "facing=north": { + "model": "mmdlib_companion:block/multiblock_demo", + "y": 0 + }, + "facing=east": { + "model": "mmdlib_companion:block/multiblock_demo", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/multiblock_demo", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/multiblock_demo", + "y": 270 + } + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_part.json b/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_part.json new file mode 100644 index 00000000..5579584e --- /dev/null +++ b/src/companion/resources/assets/mmdlib_companion/blockstates/multiblock_part.json @@ -0,0 +1,20 @@ +{ + "variants": { + "facing=north": { + "model": "mmdlib_companion:block/multiblock_part", + "y": 0 + }, + "facing=east": { + "model": "mmdlib_companion:block/multiblock_part", + "y": 90 + }, + "facing=south": { + "model": "mmdlib_companion:block/multiblock_part", + "y": 180 + }, + "facing=west": { + "model": "mmdlib_companion:block/multiblock_part", + "y": 270 + } + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json index 393abfbb..cdeb6ad6 100644 --- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json +++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json @@ -69,5 +69,12 @@ "screen.mmdlib_companion.filter.blacklist": "Blacklist: block matching entries (click for whitelist)", "screen.mmdlib_companion.filter.tag": "Item tag (namespace:path); Enter to apply", "item.mmdlib_companion.recovery_capsule": "Recovery Capsule", - "item.mmdlib_companion.recovery_capsule.help": "Use on a companion machine to restore pending fluids and energy. Any remainder stays in the capsule." + "item.mmdlib_companion.recovery_capsule.help": "Use on a companion machine to restore pending fluids and energy. Any remainder stays in the capsule.", + "block.mmdlib_companion.multiblock_demo": "Multiblock Test Bench", + "item.mmdlib_companion.multiblock_demo": "Multiblock Test Bench", + "block.mmdlib_companion.multiblock_part": "Multiblock Test Bench Part", + "screen.mmdlib_companion.structure": "Status in Info tab", + "screen.mmdlib_companion.operations": "Active ticks", + "screen.mmdlib_companion.operations.tip": "Active ticks: {value}", + "screen.mmdlib_companion.multiblock.info": "Keep the block above the middle part clear. Breaking any part dismantles this machine." } diff --git a/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_demo.json b/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_demo.json new file mode 100644 index 00000000..9c23dd01 --- /dev/null +++ b/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_demo.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "minecraft:block/iron_block" + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_part.json b/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_part.json new file mode 100644 index 00000000..10ea9bdc --- /dev/null +++ b/src/companion/resources/assets/mmdlib_companion/models/block/multiblock_part.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "minecraft:block/polished_blackstone" + } +} diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/multiblock_demo.json b/src/companion/resources/assets/mmdlib_companion/models/item/multiblock_demo.json new file mode 100644 index 00000000..0963edaf --- /dev/null +++ b/src/companion/resources/assets/mmdlib_companion/models/item/multiblock_demo.json @@ -0,0 +1,3 @@ +{ + "parent": "mmdlib_companion:block/multiblock_demo" +} diff --git a/src/companion/resources/mmdlib/screen/multiblock_demo.xml b/src/companion/resources/mmdlib/screen/multiblock_demo.xml new file mode 100644 index 00000000..4c55d121 --- /dev/null +++ b/src/companion/resources/mmdlib/screen/multiblock_demo.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/DisabledFaceTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/DisabledFaceTests.java new file mode 100644 index 00000000..7177125f --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/DisabledFaceTests.java @@ -0,0 +1,167 @@ +package zone.moddev.mc.companion.test; + +import java.util.List; +import java.util.Set; + +import io.netty.buffer.Unpooled; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; + +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import zone.moddev.mc.companion.CompanionMod; +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.RelativeFace; +import zone.moddev.mc.lib.api.capabilities.SideConfig; +import zone.moddev.mc.lib.api.redstone.RedstoneControl; +import zone.moddev.mc.lib.gui.runtime.AutomationNetwork; +import zone.moddev.mc.lib.gui.runtime.RedstoneNetwork; +import zone.moddev.mc.lib.machine.neoforge.BaseMachine; + +@GameTestHolder("mmdlib_companion") +@PrefixGameTestTemplate(false) +public final class DisabledFaceTests { + private static void require(boolean condition, String message) { + if (!condition) + throw new AssertionError(message); + } + + @GameTest(template = "empty") + public static void policySurvivesModesLoadingRotationAndCachedViews(GameTestHelper h) { + var sides = new SideConfig(IoMode.BOTH); + var items = new ItemStorage(2); + var energy = new EnergyBuffer(1000, 100); + energy.setStored(500); + for (var facing : Direction.Plane.HORIZONTAL) { + sides.disabledFaces(Set.of()); + sides.set(RelativeFace.RIGHT, IoMode.BOTH); + var side = RelativeFace.RIGHT.toWorld(facing); + var itemView = items.view(slot -> sides.mode(facing, side)); + var energyView = energy.view(() -> sides.mode(facing, side)); + require(itemView.insertItem(0, new ItemStack(Items.WHEAT), false).isEmpty(), + "Available item input"); + require(energyView.extractEnergy(10, true) == 10, "Available energy output"); + sides.disabledFaces(Set.of(RelativeFace.RIGHT)); + sides.set(RelativeFace.RIGHT, IoMode.BOTH); + require(sides.cycle(RelativeFace.RIGHT) == IoMode.DISABLED, "Blocked cycling"); + sides.copyFrom(new SideConfig(IoMode.BOTH)); + require(sides.get(RelativeFace.RIGHT) == IoMode.DISABLED, + "Loading modes preserves policy"); + require(itemView.getSlots() == 2 && !itemView.getStackInSlot(0).isEmpty(), + "Stable storage indexes and readable contents"); + for (boolean simulate : List.of(true, false)) { + require( + itemView.insertItem(1, new ItemStack(Items.WHEAT), simulate).getCount() == 1, + "Cached item input refuses"); + require(itemView.extractItem(0, 1, simulate).isEmpty(), + "Cached item output refuses"); + require(energyView.receiveEnergy(10, simulate) == 0 + && energyView.extractEnergy(10, simulate) == 0, "Cached energy refuses"); + } + sides.disabledFaces(Set.of()); + require(sides.get(RelativeFace.RIGHT) == IoMode.DISABLED, + "Re-enable never resurrects a mode"); + } + sides.disabledFaces(Set.of(RelativeFace.values())); + for (var side : Direction.values()) + require(sides.mode(Direction.NORTH, side) == IoMode.DISABLED, + "All physical faces unavailable"); + require(sides.mode(Direction.NORTH, null) == IoMode.BOTH, + "Unsided internal policy remains host-owned"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void redstonePolicyStopsOutputsInputsAndLegacyOutside(GameTestHelper h) { + var level = h.getLevel(); + var pos = h.absolutePos(new BlockPos(2, 2, 2)); + int right = RelativeFace.RIGHT.ordinal(); + for (var facing : Direction.Plane.HORIZONTAL) { + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + level.setBlock(pos, CompanionMod.CRUSHER.block.get().defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, facing), 3); + var machine = (BaseMachine) level.getBlockEntity(pos); + var control = machine.screenRedstone(); + var direction = RelativeFace.RIGHT.toWorld(facing); + control.mode(right, RedstoneControl.Mode.EMIT); + control.source(right, 0); + control.inverted(right, true); + control.updateOutputs(); + require(control.signal(direction.getOpposite()) == 15 + && control.connects(direction.getOpposite()), "Emitting before restriction"); + var saved = control.save(level.registryAccess()); + control.disabledFaces(Set.of(RelativeFace.RIGHT)); + require( + control.signal(direction.getOpposite()) == 0 + && !control.connects(direction.getOpposite()), + "Restriction immediately disconnects output"); + control.load(saved, level.registryAccess()); + require(control.mode(right) == RedstoneControl.Mode.IGNORE, + "Saved modes cannot restore blocked emit"); + require(!control.range(right, 0, 15) && !control.source(right, 0), + "Blocked edits rejected"); + control.mode(right, RedstoneControl.Mode.READ); + control.outside(right, true); + level.setBlock(pos.relative(direction), Blocks.REDSTONE_BLOCK.defaultBlockState(), 3); + control.readInputs(); + require(!control.paused() && control.incoming(right) == 0, "Unavailable input ignored"); + control.migrate(1); + control.readInputs(); + require(control.paused(), "Legacy requires power on an available face"); + control.disabledFaces(Set.of(RelativeFace.values())); + require(!control.paused(), "All blocked does not pause via Outside or legacy-any"); + var client = new RedstoneControl(machine, List.of()); + client.loadClient(control.saveClient(level.registryAccess()), level.registryAccess()); + require(client.disabledFaceMask() == 63, "World updates carry policy"); + require(!control.save(level.registryAccess()).contains("disabled_faces"), + "Disk saves exclude machine policy"); + control.disabledFaces(Set.of()); + require(control.mode(right) == RedstoneControl.Mode.IGNORE, + "Re-enabled redstone starts Ignore"); + level.setBlock(pos.relative(direction), Blocks.AIR.defaultBlockState(), 3); + } + h.succeed(); + } + + @GameTest(template = "empty") + public static void boundedPolicySnapshotsRoundTrip(GameTestHelper h) { + var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), h.getLevel().registryAccess()); + try { + int mask = 1 << RelativeFace.RIGHT.ordinal(); + var automation = new AutomationNetwork.State(1, 2, 3, true, 0, true, -1, + new String[]{"food"}, new int[6], new long[4], new long[4], new int[]{mask}); + AutomationNetwork.State.CODEC.encode(buffer, automation); + require(AutomationNetwork.State.CODEC.decode(buffer).disabledFaces()[0] == mask, + "Automation policy round trip"); + var redstone = new RedstoneNetwork.State(1, 2, 3, 4, true, 0, false, new int[48], 63); + RedstoneNetwork.State.CODEC.encode(buffer, redstone); + require(RedstoneNetwork.State.CODEC.decode(buffer).disabledFaces() == 63, + "All-blocked redstone round trip"); + try { + new RedstoneNetwork.State(1, 2, 3, 4, true, 0, false, new int[48], 64); + throw new AssertionError("Unbounded redstone mask"); + } catch (IllegalArgumentException expected) { + } + try { + new AutomationNetwork.State(1, 2, 3, true, 0, true, -1, new String[]{"food"}, + new int[6], new long[4], new long[4], new int[]{-1}); + throw new AssertionError("Unbounded automation mask"); + } catch (IllegalArgumentException expected) { + } + } finally { + buffer.release(); + } + h.succeed(); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java index 205168f6..5efd8373 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java @@ -1,6 +1,7 @@ package zone.moddev.mc.companion.test; -import java.util.*; +import java.util.List; +import java.util.Map; import com.google.gson.JsonParser; @@ -8,210 +9,217 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.gametest.framework.*; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.item.*; -import net.minecraft.world.item.crafting.*; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeHolder; -import net.neoforged.neoforge.gametest.*; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; import zone.moddev.mc.lib.gui.Screens; -import zone.moddev.mc.lib.gui.runtime.*; -import zone.moddev.mc.lib.machine.neoforge.*; -import zone.moddev.mc.lib.machine.recipe.*; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity; +import zone.moddev.mc.lib.machine.neoforge.GeneratedMachineRecipes; +import zone.moddev.mc.lib.machine.neoforge.MachineRecipeTypes; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; import zone.moddev.mc.lib.machine.recipe.generation.ReloadTags; @GameTestHolder("mmdlib_companion") @PrefixGameTestTemplate(false) public final class MachineRecipeLoadingTests { - private static final ResourceLocation TYPE = - ResourceLocation.parse("mmdlib_companion:simple_crushing"); - private static final ResourceLocation MACHINE = - ResourceLocation.parse("mmdlib_companion:simple_crusher"); - private static final String SINGLE = - """ - {"type":"mmdlib_companion:simple_crushing", - "inputs":{"input":[{"items":{"item":"minecraft:cobblestone"}}]}, - "outputs":{"output":[{"item":{"id":"minecraft:gravel"}}]},"time":1} - """; + private static final ResourceLocation TYPE = ResourceLocation + .parse("mmdlib_companion:simple_crushing"); + private static final ResourceLocation MACHINE = ResourceLocation + .parse("mmdlib_companion:simple_crusher"); + private static final String SINGLE = """ + {"type":"mmdlib_companion:simple_crushing", + "inputs":{"input":[{"items":{"item":"minecraft:cobblestone"}}]}, + "outputs":{"output":[{"item":{"id":"minecraft:gravel"}}]},"time":1} + """; - private static RecipeHolder read(GameTestHelper h, String id, String json) { - return new RecipeHolder<>( - ResourceLocation.parse(id), - Recipe.CODEC - .parse( - h.getLevel().registryAccess().createSerializationContext(JsonOps.INSTANCE), - JsonParser.parseString(json)) - .getOrThrow()); - } + private static RecipeHolder read(GameTestHelper h, String id, String json) { + return new RecipeHolder<>( + ResourceLocation.parse(id), + Recipe.CODEC + .parse( + h.getLevel().registryAccess().createSerializationContext(JsonOps.INSTANCE), + JsonParser.parseString(json)) + .getOrThrow()); + } - private static List> load(GameTestHelper h, RecipeHolder... recipes) { - return GeneratedMachineRecipes.generate( - List.of(recipes), h.getLevel().registryAccess(), new ReloadTags(Map.of())); - } + private static List> load(GameTestHelper h, RecipeHolder... recipes) { + return GeneratedMachineRecipes.generate( + List.of(recipes), h.getLevel().registryAccess(), new ReloadTags(Map.of())); + } - private static void require(boolean condition, String message) { - if (!condition) throw new AssertionError(message); - } + private static void require(boolean condition, String message) { + if (!condition) + throw new AssertionError(message); + } - @GameTest(template = "empty") - public static void authoredRecipesValidateGroupsAndResourceKinds(GameTestHelper h) { - for (var json : - List.of( + @GameTest(template = "empty") + public static void authoredRecipesValidateGroupsAndResourceKinds(GameTestHelper h) { + for (var json : List.of( SINGLE.replace("\"input\":", "\"typo\":"), SINGLE.replace("\"output\":", "\"typo\":"), SINGLE.replace("\"input\":", "\"energy\":"), SINGLE.replace("\"output\":", "\"energy\":"))) { - try { - load(h, read(h, "mmdlib_companion:bad_recipe", json)); - throw new AssertionError("Invalid authored recipe accepted"); - } catch (IllegalArgumentException expected) { + try { + load(h, read(h, "mmdlib_companion:bad_recipe", json)); + throw new AssertionError("Invalid authored recipe accepted"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("mmdlib_companion:bad_recipe") + && expected.getMessage().contains(MACHINE.toString()), + "Error identifies both recipe and machine"); + } + } require( - expected.getMessage().contains("mmdlib_companion:bad_recipe") - && expected.getMessage().contains(MACHINE.toString()), - "Error identifies both recipe and machine"); - } + load(h, read(h, "mmdlib_companion:valid_recipe", SINGLE)).size() == 1, + "Single JSON recipes remain supported"); + h.succeed(); } - require( - load(h, read(h, "mmdlib_companion:valid_recipe", SINGLE)).size() == 1, - "Single JSON recipes remain supported"); - h.succeed(); - } - @GameTest(template = "empty") - public static void bundlesExpandWithStableIdsAndRejectCollisions(GameTestHelper h) { - String prefix = "{\"type\":\"mmdlib:machine_recipes\",\"recipes\":{"; - var first = - load( + @GameTest(template = "empty") + public static void bundlesExpandWithStableIdsAndRejectCollisions(GameTestHelper h) { + String prefix = "{\"type\":\"mmdlib:machine_recipes\",\"recipes\":{"; + var first = load( h, read( h, "mmdlib_companion:batch", prefix + "\"first\":" + SINGLE + ",\"second\":" + SINGLE + "}}")); - var reordered = - load( + var reordered = load( h, read( h, "mmdlib_companion:batch", prefix + "\"second\":" + SINGLE + ",\"first\":" + SINGLE + "}}")); - require( - first.stream() - .map(RecipeHolder::id) - .toList() - .equals(reordered.stream().map(RecipeHolder::id).toList()), - "Reordering does not change IDs"); - require( - first.size() == 2 && first.stream().allMatch(r -> r.value() instanceof MachineRecipeEntry), - "Only flattened ordinary machine recipes survive"); - try { - load( - h, - read(h, "mmdlib_companion:batch", prefix + "\"first\":" + SINGLE + "}}"), - read(h, "mmdlib_companion:batch/first", SINGLE)); - throw new AssertionError("Duplicate authored ID accepted"); - } catch (IllegalArgumentException expected) { - require( - expected.getMessage().contains("mmdlib_companion:batch/first"), - "Duplicate diagnostic names stable ID"); - } - var conditional = - SINGLE.replace( + require( + first.stream() + .map(RecipeHolder::id) + .toList() + .equals(reordered.stream().map(RecipeHolder::id).toList()), + "Reordering does not change IDs"); + require( + first.size() == 2 + && first.stream().allMatch(r -> r.value() instanceof MachineRecipeEntry), + "Only flattened ordinary machine recipes survive"); + try { + load( + h, + read(h, "mmdlib_companion:batch", prefix + "\"first\":" + SINGLE + "}}"), + read(h, "mmdlib_companion:batch/first", SINGLE)); + throw new AssertionError("Duplicate authored ID accepted"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("mmdlib_companion:batch/first"), + "Duplicate diagnostic names stable ID"); + } + var conditional = SINGLE.replace( "\"time\":1", "\"neoforge:conditions\":[{\"type\":\"neoforge:false\"}],\"time\":1"); - require( - load( + require( + load( + h, + read( h, - read( - h, - "mmdlib_companion:conditional", - prefix + "\"skipped\":" + conditional + ",\"kept\":" + SINGLE + "}}")) - .size() - == 1, - "Per-entry conditions are respected"); - require( - load(h, read(h, "mmdlib_companion:empty", prefix + "}}")).isEmpty(), - "Empty replacement removes all bundle entries"); - try { - load( - h, - read( - h, - "mmdlib_companion:bad_bundle", - prefix + "\"bad\":" + SINGLE.replace("\"input\":", "\"typo\":") + "}}")); - throw new AssertionError("Invalid bundled group accepted"); - } catch (IllegalArgumentException expected) { - require( - expected.getMessage().contains("mmdlib_companion:bad_bundle/bad"), - "Bundled diagnostic names file and entry"); + "mmdlib_companion:conditional", + prefix + "\"skipped\":" + conditional + ",\"kept\":" + SINGLE + "}}")) + .size() == 1, + "Per-entry conditions are respected"); + require( + load(h, read(h, "mmdlib_companion:empty", prefix + "}}")).isEmpty(), + "Empty replacement removes all bundle entries"); + try { + load( + h, + read( + h, + "mmdlib_companion:bad_bundle", + prefix + "\"bad\":" + SINGLE.replace("\"input\":", "\"typo\":") + "}}")); + throw new AssertionError("Invalid bundled group accepted"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("mmdlib_companion:bad_bundle/bad"), + "Bundled diagnostic names file and entry"); + } + h.succeed(); } - h.succeed(); - } - - @GameTest(template = "empty") - public static void realBundleFileLoadsIntoTheProcessor(GameTestHelper h) { - var manager = h.getLevel().getRecipeManager(); - require( - manager.byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone")).isEmpty(), - "Bundle container removed before publication"); - for (var name : List.of("cobblestone", "gravel")) - require( - manager - .byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone/" + name)) - .orElseThrow() - .value() - instanceof MachineRecipeEntry, - "Bundled recipe loaded at startup"); - var pos = h.absolutePos(new BlockPos(2, 2, 2)); - h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(MACHINE).defaultBlockState(), 3); - var machine = (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); - machine.energy.setStored(2000); - require( - machine.items.insertItem(0, new ItemStack(Items.COBBLESTONE), false).isEmpty(), - "Bundled input admitted"); - for (int i = 0; i < 101; i++) machine.serverTick(); - require(machine.items.getStackInSlot(1).is(Items.GRAVEL), "Bundled recipe processed normally"); - h.succeed(); - } - @GameTest(template = "empty") - public static void menuHandshakeIncludesMachineChecksum(GameTestHelper h) { - var pos = h.absolutePos(new BlockPos(2, 2, 2)); - h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(MACHINE).defaultBlockState(), 3); - var machine = (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); - var screen = Screens.get(MACHINE).orElseThrow().screen(); - var player = h.makeMockServerPlayerInLevel(); - var expected = ScreenRuntime.fingerprint(screen, machine); - require(!expected.equals(screen.fingerprint()), "Compatibility includes more than the screen"); - require( - machine.screenDefinitionFingerprint().equals(machine.definition.checksum()), - "Host exposes resolved XML CRC"); - for (var fingerprint : List.of(expected, screen.fingerprint())) { - var data = - new RegistryFriendlyByteBuf( - io.netty.buffer.Unpooled.buffer(), h.getLevel().registryAccess()); - try { - data.writeBlockPos(pos); - data.writeUtf(fingerprint); - data.writeLong(42); - if (fingerprint.equals(expected)) { - require( - new MachineMenu(1, player.getInventory(), data).widgetSession() == 42, - "Matching definition opens normally"); - } else { - try { - new MachineMenu(1, player.getInventory(), data); - throw new AssertionError("Mismatched machine definition accepted"); - } catch (IllegalStateException error) { + @GameTest(template = "empty") + public static void realBundleFileLoadsIntoTheProcessor(GameTestHelper h) { + var manager = h.getLevel().getRecipeManager(); + require( + manager.byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone")) + .isEmpty(), + "Bundle container removed before publication"); + for (var name : List.of("cobblestone", "gravel")) require( - error.getMessage().contains("machine definitions differ"), - "Clear compatibility diagnostic"); - } + manager + .byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone/" + name)) + .orElseThrow() + .value() instanceof MachineRecipeEntry, + "Bundled recipe loaded at startup"); + var pos = h.absolutePos(new BlockPos(2, 2, 2)); + h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(MACHINE).defaultBlockState(), 3); + var machine = (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); + machine.energy.setStored(2000); + require( + machine.items.insertItem(0, new ItemStack(Items.COBBLESTONE), false).isEmpty(), + "Bundled input admitted"); + for (int i = 0; i < 101; i++) + machine.serverTick(); + require(machine.items.getStackInSlot(1).is(Items.GRAVEL), + "Bundled recipe processed normally"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void menuHandshakeIncludesMachineChecksum(GameTestHelper h) { + var pos = h.absolutePos(new BlockPos(2, 2, 2)); + h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(MACHINE).defaultBlockState(), 3); + var machine = (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); + var screen = Screens.get(MACHINE).orElseThrow().screen(); + var player = h.makeMockServerPlayerInLevel(); + var expected = ScreenRuntime.fingerprint(screen, machine); + require(!expected.equals(screen.fingerprint()), + "Compatibility includes more than the screen"); + require( + machine.screenDefinitionFingerprint().equals(machine.definition.checksum()), + "Host exposes resolved XML CRC"); + for (var fingerprint : List.of(expected, screen.fingerprint())) { + var data = new RegistryFriendlyByteBuf( + io.netty.buffer.Unpooled.buffer(), h.getLevel().registryAccess()); + try { + data.writeBlockPos(pos); + data.writeUtf(fingerprint); + data.writeLong(42); + data.writeBlockPos(machine.getBlockPos()); + if (fingerprint.equals(expected)) { + require( + new MachineMenu(1, player.getInventory(), data).widgetSession() == 42, + "Matching definition opens normally"); + } else { + try { + new MachineMenu(1, player.getInventory(), data); + throw new AssertionError("Mismatched machine definition accepted"); + } catch (IllegalStateException error) { + require( + error.getMessage().contains("machine definitions differ"), + "Clear compatibility diagnostic"); + } + } + } finally { + data.release(); + } } - } finally { - data.release(); - } + h.succeed(); } - h.succeed(); - } } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockClientSmoke.java new file mode 100644 index 00000000..c812f7c0 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockClientSmoke.java @@ -0,0 +1,197 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.Difficulty; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.WorldDataConfiguration; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.presets.WorldPresets; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RenderFrameEvent; + +import zone.moddev.mc.companion.MultiblockDemo; +import zone.moddev.mc.companion.MultiblockDemoEntity; +import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; + +/** Focused real-client coverage of member sync, remote menu opening and world teardown. */ +@EventBusSubscriber(modid = "mmdlib_companion", value = Dist.CLIENT) +public final class MultiblockClientSmoke { + private static final BlockPos POS = new BlockPos(0, 100, 0); + private static final long DEADLINE = System.currentTimeMillis() + 240000; + private static boolean started, placed, requested, finished; + private static volatile boolean removed; + private static int frames; + private static int ticks; + + @SubscribeEvent + public static void tick(ClientTickEvent.Post event) { + if (!Boolean.getBoolean("mmdlib.multiblockSmoke") || finished) + return; + var mc = Minecraft.getInstance(); + try { + if (System.currentTimeMillis() > DEADLINE) + throw new AssertionError("Timed out: " + mc.screen); + if (!started && mc.screen instanceof TitleScreen) { + started = true; + var settings = new LevelSettings("Multiblock smoke", GameType.CREATIVE, false, + Difficulty.PEACEFUL, true, new GameRules(), WorldDataConfiguration.DEFAULT); + mc.createWorldOpenFlows().createFreshLevel( + "multiblock-" + System.currentTimeMillis(), settings, + new WorldOptions(1, false, false), + registries -> registries.registryOrThrow(Registries.WORLD_PRESET) + .getHolderOrThrow(WorldPresets.FLAT).value().createWorldDimensions(), + mc.screen); + } + if (mc.player == null || mc.level == null) + return; + if (++ticks % 200 == 0) { + var entity = mc.level.getBlockEntity(POS); + MMDLib.LOGGER.info( + "MULTIBLOCK_CLIENT_PROGRESS: placed={} requested={} screen={} controller={} formation={} pos={}", + placed, requested, mc.screen, entity, + entity instanceof MultiblockDemoEntity machine + ? machine.multiblock().formation() + : null, + mc.player.position()); + } + var server = mc.getSingleplayerServer(); + if (!placed) { + placed = true; + server.execute(() -> { + try { + var player = server.getPlayerList().getPlayers().getFirst(); + var level = player.serverLevel(); + level.getChunkAt(POS); + for (int x = -5; x <= 12; x++) + for (int z = -5; z <= 5; z++) + level.setBlock(new BlockPos(x, 99, z), + Blocks.STONE.defaultBlockState(), 3); + player.teleportTo(1.5, 100, 4.5); + player.setYRot(0); + player.setGameMode(GameType.SURVIVAL); + player.setItemInHand(InteractionHand.MAIN_HAND, + new ItemStack(MultiblockDemo.ITEM.get(), 2)); + var use = new UseOnContext(player, InteractionHand.MAIN_HAND, + new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false)); + if (!net.neoforged.neoforge.common.CommonHooks.onPlaceItemIntoWorld(use) + .consumesAction() + || player.getMainHandItem().getCount() != 1) + throw new AssertionError( + "Real survival placement did not consume exactly one item"); + var entity = (MultiblockDemoEntity) level.getBlockEntity(POS); + entity.items.insertItem(0, new ItemStack(Items.IRON_INGOT, 3), false); + entity.energy.insert(7000, false); + MMDLib.LOGGER.info("MULTIBLOCK_CLIENT_PLACED: {}", + entity.multiblock().formation()); + } catch (Throwable failure) { + fail(failure); + } + }); + } + if (!requested && mc.level.getBlockEntity(POS) instanceof MultiblockDemoEntity entity + && entity.multiblock().formation() != null + && mc.level.getBlockEntity(POS.east(2)) != null) { + requested = true; + server.execute(() -> { + try { + var player = server.getPlayerList().getPlayers().getFirst(); + player.teleportTo(10, 100, .5); + var machine = (MultiblockDemoEntity) player.serverLevel() + .getBlockEntity(POS); + if (machine.items.getStackInSlot(0).getCount() != 3) + throw new AssertionError("Server fixture inventory was lost: " + + machine.items.getStackInSlot(0)); + if (player.canInteractWithBlock(POS, 4.0)) + throw new AssertionError("Player still reaches controller"); + if (!ScreenRuntime.open(player, player.serverLevel().getBlockEntity(POS), + POS.east(2))) + throw new AssertionError("Remote menu refused"); + } catch (Throwable failure) { + fail(failure); + } + }); + } + if (removed && mc.level.getBlockState(POS).isAir() + && mc.level.getBlockState(POS.east()).isAir()) { + finished = true; + MMDLib.LOGGER.info( + "MULTIBLOCK_CLIENT_PASS: survival placement, part sync, remote menu and teardown"); + mc.stop(); + } + } catch (Throwable failure) { + fail(failure); + } + } + + @SubscribeEvent + public static void rendered(RenderFrameEvent.Post event) { + if (!Boolean.getBoolean("mmdlib.multiblockSmoke") || finished || removed) + return; + var mc = Minecraft.getInstance(); + if (!(mc.screen instanceof MachineScreen screen) + || !screen.getMenu().bound.screen().id().equals(MultiblockDemo.definition().id())) + return; + try { + if (++frames < 30) + return; + if (!screen.getMenu().stillValid(mc.player)) + throw new AssertionError("Client member anchor not synchronized"); + var entity = (MultiblockDemoEntity) mc.level.getBlockEntity(POS); + if (entity.multiblock().status() != MultiblockInstance.Status.READY) + throw new AssertionError("Client structure state missing"); + var storage = screen.getMenu().nativeSlots().entries().stream() + .filter(entry -> entry.element() != null && entry + .role() == zone.moddev.mc.lib.gui.runtime.slot.MenuSlots.Role.MACHINE + && entry.inventory().equals("items")) + .findFirst().orElseThrow(); + if (!storage.slot().getItem().is(Items.IRON_INGOT) + || storage.slot().getItem().getCount() != 3) { + if (frames < 300) + return; + throw new AssertionError("Controller inventory not synchronized: " + + storage.slot().getItem()); + } + Screenshot.grab(mc.gameDirectory, "multiblock-menu.png", mc.getMainRenderTarget(), + message -> { + }); + removed = true; + mc.player.closeContainer(); + mc.getSingleplayerServer().execute(() -> { + try { + mc.getSingleplayerServer().overworld().removeBlock(POS.east(2), false); + } catch (Throwable failure) { + fail(failure); + } + }); + } catch (Throwable failure) { + fail(failure); + } + } + + private static void fail(Throwable failure) { + finished = true; + MMDLib.LOGGER.error("MULTIBLOCK_CLIENT_FAIL", failure); + Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java new file mode 100644 index 00000000..2e802045 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java @@ -0,0 +1,393 @@ +package zone.moddev.mc.companion.test; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.BlockEvent; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import zone.moddev.mc.companion.MultiblockDemo; +import zone.moddev.mc.companion.MultiblockDemoEntity; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; +import zone.moddev.mc.lib.machine.multiblock.ResolvedStructure; +import zone.moddev.mc.lib.machine.multiblock.StructureMember; +import zone.moddev.mc.lib.machine.neoforge.MultiblockMembers; +import zone.moddev.mc.lib.machine.neoforge.MultiblockPlacement; +import zone.moddev.mc.lib.machine.neoforge.MultiblockPorts; + +@GameTestHolder("mmdlib_companion") +@PrefixGameTestTemplate(false) +public final class MultiblockTests { + @GameTest(template = "empty", timeoutTicks = 200) + public static void unavailableChunkSuspendsWithoutLoadingAndResumesSameIdentity( + GameTestHelper helper) { + var level = helper.getLevel(); + var pos = origin(helper); + BlockPos offset = null; + for (int x = -128; x <= 128 && offset == null; x += 16) + for (int z = -128; z <= 128; z += 16) + if (!level.hasChunkAt(pos.offset(x, 0, z))) { + offset = new BlockPos(x, 0, z); + break; + } + require(offset != null, "Fixture has an unloaded neighbor within bounded extent"); + var partPos = pos.offset(offset); + var candidate = new ResolvedStructure(List.of( + new StructureMember("controller", "controller", BlockPos.ZERO, + MultiblockDemo.CONTROLLER.get().defaultBlockState()), + new StructureMember("socket", "socket", offset, + MultiblockDemo.PART.get().defaultBlockState())), + List.of(), Map.of()); + var definition = new zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition( + net.minecraft.resources.ResourceLocation.parse("mmdlib_companion:chunk_fixture"), 1, + new zone.moddev.mc.lib.machine.multiblock.FixedLayout(candidate), 16, 128, + zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition.Dismantling.REMOVE_OWNED, + Map.of(), List.of()); + level.setBlock(pos, MultiblockDemo.CONTROLLER.get().defaultBlockState(), 3); + var entity = new MultiblockDemoEntity(pos, level.getBlockState(pos), definition); + level.setBlockEntity(entity); + require(entity.multiblock().stage(candidate, Direction.NORTH), "Manifest staged"); + var saved = entity.multiblock().save(); + saved.putBoolean("committed", true); + entity.multiblock().load(saved, level.registryAccess()); + var identity = entity.multiblock().formation(); + entity.serverTick(); + require(entity.multiblock().status() == MultiblockInstance.Status.SUSPENDED, + "Unloaded member suspends"); + require(!level.hasChunkAt(partPos), "Validation did not load the missing chunk"); + require(level.getBlockEntity(pos) == entity && entity.getOperations() == 0, + "Owner and resources remain inactive"); + // The test explicitly supplies the returning chunk and part, as world loading would. + level.getChunkAt(partPos); + level.setBlock(partPos, MultiblockDemo.PART.get().defaultBlockState(), 3); + ((zone.moddev.mc.lib.machine.multiblock.LinkedMember) level.getBlockEntity(partPos)) + .memberLink(entity.multiblock().memberLink("socket")); + entity.serverTick(); + require(entity.multiblock().ready() && identity.equals(entity.multiblock().formation()), + "Same formation resumes after verification"); + entity.multiblock().dismantle(MultiblockInstance.Cause.CREATIVE); + helper.succeed(); + } + @GameTest(template = "empty") + public static void existingMembersUseSameLifecycleAndLeaveBlocksPolicy(GameTestHelper helper) { + var base = MultiblockDemo.definition(); + var definition = new zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition(base.id(), + base.version(), + base.resolver(), base.maxMembers(), base.maxExtent(), + zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition.Dismantling.LEAVE_BLOCKS, + base.rules(), base.ports()); + var level = helper.getLevel(); + var pos = origin(helper); + var candidate = ((zone.moddev.mc.lib.machine.multiblock.FixedLayout) base.resolver()) + .structure(); + for (var member : candidate.members()) + level.setBlock(pos.offset(member.offset()), member.state(), 3); + var entity = new MultiblockDemoEntity(pos, level.getBlockState(pos), definition); + level.setBlockEntity(entity); + entity.items.insertItem(0, new ItemStack(Items.GOLD_INGOT, 4), false); + require(entity.multiblock().form(candidate, Direction.NORTH), + "Existing members form without placement"); + var oldIdentity = entity.multiblock().formation(); + entity.multiblock().dismantle(MultiblockInstance.Cause.INVALIDATED); + for (var member : candidate.members()) { + var memberPos = pos.offset(member.offset()); + require(level.getBlockState(memberPos).equals(member.state()), + "Player-built member stays"); + require(MultiblockMembers.INSTANCE.link(level, memberPos) == null, "Claim released"); + } + require(entity.items.getStackInSlot(0).getCount() == 4, "Controller contents remain"); + require(entity.multiblock().form(candidate, Direction.NORTH), + "Explicit reformation succeeds"); + require(!oldIdentity.equals(entity.multiblock().formation()), + "Reformation has a new identity"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void interruptedPlacementCleansUpWithoutReward(GameTestHelper helper) { + var level = helper.getLevel(); + var pos = origin(helper); + level.setBlock(pos, MultiblockDemo.CONTROLLER.get().defaultBlockState(), 3); + var entity = (MultiblockDemoEntity) level.getBlockEntity(pos); + var candidate = ((zone.moddev.mc.lib.machine.multiblock.FixedLayout) MultiblockDemo + .definition().resolver()).structure(); + require(entity.multiblock().stage(candidate, Direction.NORTH), "Pending placement staged"); + level.setBlock(pos.east(), MultiblockDemo.PART.get().defaultBlockState(), 3); + ((zone.moddev.mc.lib.machine.multiblock.LinkedMember) level.getBlockEntity(pos.east())) + .memberLink(entity.multiblock().memberLink("belt")); + var saved = entity.saveWithFullMetadata(level.registryAccess()); + var restored = (MultiblockDemoEntity) BlockEntity.loadStatic(pos, entity.getBlockState(), + saved, level.registryAccess()); + level.setBlockEntity(restored); + restored.serverTick(); + require(level.getBlockState(pos).isAir() && level.getBlockState(pos.east()).isAir(), + "Partial owned placement cleaned up"); + require(level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(pos).inflate(2)).isEmpty(), + "Uncommitted placement creates no rewards"); + helper.succeed(); + } + private static BlockPos origin(GameTestHelper helper) { + return helper.absolutePos(new BlockPos(2, 2, 2)); + } + private static BlockPlaceContext context(GameTestHelper helper, Direction facing) { + var player = helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL); + var pos = origin(helper); + player.setPos(pos.getX() + .5, pos.getY() + 4, pos.getZ() + .5); + player.setYRot(facing.getOpposite().toYRot()); + player.setItemInHand(InteractionHand.MAIN_HAND, + new ItemStack(MultiblockDemo.ITEM.get(), 2)); + return new BlockPlaceContext(player, InteractionHand.MAIN_HAND, player.getMainHandItem(), + new BlockHitResult(Vec3.atCenterOf(pos), Direction.UP, pos, false)); + } + private static MultiblockDemoEntity place(GameTestHelper helper, Direction facing) { + var result = MultiblockPlacement.place(context(helper, facing), + MultiblockDemo.definition()); + require(result.success(), "Placement: " + result.diagnostic()); + return (MultiblockDemoEntity) helper.getLevel().getBlockEntity(origin(helper)); + } + private static BlockPos socket(MultiblockDemoEntity entity) { + return entity.multiblock().transform().position(new BlockPos(2, 0, 0)); + } + private static void require(boolean condition, String message) { + if (!condition) + throw new AssertionError(message); + } + + @GameTest(template = "empty") + public static void wholePlacementRotatesLinksAndConsumesOneItem(GameTestHelper helper) { + for (var facing : Direction.Plane.HORIZONTAL) { + var context = context(helper, facing); + var use = new UseOnContext(context.getPlayer(), InteractionHand.MAIN_HAND, + new BlockHitResult(Vec3.atCenterOf(origin(helper)), Direction.UP, origin(helper), + false)); + require( + net.neoforged.neoforge.common.CommonHooks.onPlaceItemIntoWorld(use) + .consumesAction(), + "Item placement succeeds"); + require(context.getPlayer().getMainHandItem().getCount() == 1, + "Exactly one item consumed"); + var entity = (MultiblockDemoEntity) helper.getLevel().getBlockEntity(origin(helper)); + require(entity.multiblock().ready(), "Machine is ready"); + require(entity.multiblock().transform().facing() == facing, "Facing follows placement"); + require( + MultiblockMembers.resolve(helper.getLevel(), socket(entity)) == entity.multiblock(), + "Part resolves reciprocally"); + entity.multiblock().dismantle(MultiblockInstance.Cause.CREATIVE); + require(helper.getLevel().getBlockState(origin(helper)).isAir(), "Controller removed"); + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void obstructionAndCancelledPlacementRollBack(GameTestHelper helper) { + var level = helper.getLevel(); + var pos = origin(helper); + level.setBlock(pos.east(), Blocks.STONE.defaultBlockState(), 3); + require( + !MultiblockPlacement + .place(context(helper, Direction.NORTH), MultiblockDemo.definition()).success(), + "Obstruction refused"); + require(level.getBlockState(pos).isAir(), "Preflight writes nothing"); + level.removeBlock(pos.east(), false); + Consumer cancel = event -> { + if (event.getPos().equals(pos)) + event.setCanceled(true); + }; + NeoForge.EVENT_BUS.addListener(cancel); + try { + var context = context(helper, Direction.NORTH); + require(!MultiblockPlacement.place(context, MultiblockDemo.definition()).success(), + "Cancelled event refused"); + require(context.getItemInHand().getCount() == 2, "Failure consumes nothing"); + for (int x = 0; x < 3; x++) + require(level.getBlockState(pos.east(x)).isAir(), "Cancelled writes restored"); + } finally { + NeoForge.EVENT_BUS.unregister(cancel); + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void rollbackPreservesConcurrentReplacement(GameTestHelper helper) { + var level = helper.getLevel(); + var pos = origin(helper); + Consumer replace = event -> { + if (event.getPos().equals(pos)) + level.setBlock(pos.east(), Blocks.DIAMOND_BLOCK.defaultBlockState(), 3); + }; + NeoForge.EVENT_BUS.addListener(replace); + try { + require( + !MultiblockPlacement + .place(context(helper, Direction.NORTH), MultiblockDemo.definition()).success(), + "Modified plan refused"); + require(level.getBlockState(pos.east()).is(Blocks.DIAMOND_BLOCK), + "Unrelated replacement survives rollback"); + require(level.getBlockState(pos).isAir() && level.getBlockState(pos.east(2)).isAir(), + "Owned partial parts removed"); + } finally { + NeoForge.EVENT_BUS.unregister(replace); + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void cachedPortsSuspendResumeAndNeverRetarget(GameTestHelper helper) { + var entity = place(helper, Direction.NORTH); + var level = helper.getLevel(); + var socket = level.getBlockEntity(socket(entity)); + var energy = MultiblockPorts.energy(socket, Direction.NORTH); + var items = MultiblockPorts.items(socket, Direction.NORTH); + var fluid = MultiblockPorts.fluids(socket, Direction.NORTH); + require(energy != null && items != null && fluid != null, + "All three resource adapters exposed"); + require(MultiblockPorts.energy(socket, null) == null, "Unsided access refused by policy"); + require(energy.receiveEnergy(500, false) == 500, "Energy forwards"); + require(items.insertItem(0, new ItemStack(Items.IRON_INGOT, 3), false).isEmpty(), + "Items forward"); + require(fluid.fill(new FluidStack(Fluids.WATER, 1000), + IFluidHandler.FluidAction.EXECUTE) == 1000, "Fluid forwards"); + var blocker = origin(helper).east().above(); + level.setBlock(blocker, Blocks.STONE.defaultBlockState(), 3); + int operations = entity.getOperations(); + entity.serverTick(); + require( + entity.getOperations() == operations + && entity.multiblock().status() == MultiblockInstance.Status.SUSPENDED, + "Custom tick hooks suspended"); + require(energy.extractEnergy(100, false) == 0 && items.extractItem(0, 1, false).isEmpty() + && fluid.drain(100, IFluidHandler.FluidAction.EXECUTE).isEmpty(), + "Cached handlers become inert"); + level.removeBlock(blocker, false); + entity.serverTick(); + require(entity.getOperations() == operations + 1 && energy.extractEnergy(1, false) == 1, + "Same formation resumes"); + entity.multiblock().dismantle(MultiblockInstance.Cause.CREATIVE); + var replacement = place(helper, Direction.NORTH); + replacement.energy.insert(100, false); + require(energy.extractEnergy(100, false) == 0, "Old energy wrapper cannot retarget"); + require(items.insertItem(0, new ItemStack(Items.DIAMOND), false).is(Items.DIAMOND), + "Old item wrapper refuses insertion"); + require(fluid.fill(new FluidStack(Fluids.WATER, 1), IFluidHandler.FluidAction.EXECUTE) == 0, + "Old fluid wrapper refuses filling"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void manifestReloadAndIncompatibleDefinitionPreserveStorage( + GameTestHelper helper) { + var entity = place(helper, Direction.EAST); + var level = helper.getLevel(); + entity.items.insertItem(0, new ItemStack(Items.GOLD_INGOT, 7), false); + entity.energy.insert(700, false); + var formation = entity.multiblock().formation(); + var saved = entity.saveWithFullMetadata(level.registryAccess()); + var restored = (MultiblockDemoEntity) BlockEntity.loadStatic(entity.getBlockPos(), + entity.getBlockState(), saved, level.registryAccess()); + level.setBlockEntity(restored); + require(restored.multiblock().status() == MultiblockInstance.Status.SUSPENDED, + "Saved ready state is not trusted"); + restored.serverTick(); + require( + restored.multiblock().ready() && formation.equals(restored.multiblock().formation()), + "Reload verifies existing identity"); + saved.getCompound("multiblock").putInt("version", 999); + var incompatible = (MultiblockDemoEntity) BlockEntity.loadStatic(entity.getBlockPos(), + entity.getBlockState(), saved, level.registryAccess()); + level.setBlockEntity(incompatible); + incompatible.serverTick(); + require(!incompatible.multiblock().ready(), "Changed definition is inactive"); + require(incompatible.items.getStackInSlot(0).getCount() == 7 + && incompatible.energy.stored() == 700, "Resources retained"); + require(incompatible.saveWithFullMetadata(level.registryAccess()).getCompound("multiblock") + .getInt("version") == 999, "Unknown manifest preserved"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void breakingPartDropsOnceAndPreservesReplacement(GameTestHelper helper) { + var entity = place(helper, Direction.NORTH); + var level = helper.getLevel(); + entity.items.insertItem(0, new ItemStack(Items.DIAMOND, 3), false); + var part = socket(entity); + entity.multiblock().dismantle(MultiblockInstance.Cause.SURVIVAL); + entity.multiblock().dismantle(MultiblockInstance.Cause.SURVIVAL); + var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(origin(helper)).inflate(3)); + require(drops.stream().filter(e -> e.getItem().is(MultiblockDemo.ITEM.get())) + .mapToInt(e -> e.getItem().getCount()).sum() == 1, "One machine item"); + require(drops.stream().filter(e -> e.getItem().is(Items.DIAMOND)) + .mapToInt(e -> e.getItem().getCount()).sum() == 3, "Contents released once"); + require(level.getBlockState(part).isAir(), "Part removed"); + var next = place(helper, Direction.NORTH); + level.setBlock(socket(next), Blocks.GOLD_BLOCK.defaultBlockState(), 3); + next.serverTick(); + require(level.getBlockState(part).is(Blocks.GOLD_BLOCK), + "Replacement survives invalidation"); + require(level.getBlockState(origin(helper)).isAir(), "Remaining owned controller removed"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void remoteMenuAnchorUsesPartReachAndClosesOnLoss(GameTestHelper helper) { + var entity = place(helper, Direction.NORTH); + var player = helper.makeMockServerPlayerInLevel(); + player.setGameMode(net.minecraft.world.level.GameType.SURVIVAL); + var pos = origin(helper); + player.setPos(pos.getX() + 10, pos.getY(), pos.getZ() + .5); + var menu = new MachineMenu(31, player.getInventory(), entity, 1L, socket(entity)); + require(!player.canInteractWithBlock(pos, 4.0), + "Fixture player is beyond controller reach"); + require(menu.stillValid(player), "Verified part supplies reach"); + var forged = new MachineMenu(32, player.getInventory(), entity, 2L, player.blockPosition()); + require(!forged.stillValid(player), "Arbitrary nearby anchor refused"); + helper.getLevel().removeBlock(socket(entity), false); + require(!menu.stillValid(player), "Removed member closes menu"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void definitionRejectsDuplicateAndOutOfBoundsMembers(GameTestHelper helper) { + var state = MultiblockDemo.CONTROLLER.get().defaultBlockState(); + try { + new ResolvedStructure( + List.of(new StructureMember("controller", "controller", BlockPos.ZERO, state), + new StructureMember("duplicate", "belt", BlockPos.ZERO, state)), + List.of(), Map.of()); + throw new AssertionError("Duplicate position accepted"); + } catch (IllegalArgumentException expected) { + } + var candidate = new ResolvedStructure( + List.of(new StructureMember("controller", "controller", BlockPos.ZERO, state), + new StructureMember("far", "belt", new BlockPos(129, 0, 0), state)), + List.of(), Map.of()); + try { + MultiblockDemo.definition().validate(candidate); + throw new AssertionError("Invalid manifest accepted"); + } catch (IllegalArgumentException expected) { + } + helper.succeed(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java index 0d93e8e2..2e0aab92 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java @@ -86,7 +86,11 @@ public AutomationInventory(BlockEntity owner, List> groups) { } else if (!ambiguous.contains(key)) indexed.put(member, group); } - group.sides().onChange(this::viewChanged); + group.sides().onChange(() -> { + viewChanged(); + if (owner.getLevel() != null) + owner.getLevel().invalidateCapabilities(owner.getBlockPos()); + }); } var inventories = new LinkedHashMap(); for (int g = 0; g < groups.size(); g++) { diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java b/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java index 90a9e35b..59c69409 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/ResourceGroup.java @@ -151,7 +151,7 @@ public IoMode next(IoMode current, boolean reverse) { /** Checked compare-and-set: concurrent viewers cannot cycle a newer value accidentally. */ public boolean cycle(RelativeFace face, IoMode expected, boolean reverse) { - if (sides.get(face) != expected) + if (!sides.faceAvailable(face) || sides.get(face) != expected) return false; sides.set(face, next(expected, reverse)); return true; diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java index 2ff3aa87..8d55edd4 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/EnergyBuffer.java @@ -12,6 +12,7 @@ import zone.moddev.mc.lib.api.stats.Statistics; import zone.moddev.mc.lib.api.stats.StatsProvider; +import zone.moddev.mc.lib.api.util.ChangeSignal; /** * A machine's energy, counted as a {@code long} so large buffers do not overflow. Automation sees @@ -20,240 +21,272 @@ * the rates. */ public class EnergyBuffer implements IEnergyStorage, INBTSerializable, StatsProvider { - private java.math.BigInteger inserted = java.math.BigInteger.ZERO; - private java.math.BigInteger extracted = java.math.BigInteger.ZERO; - private java.math.BigInteger used = java.math.BigInteger.ZERO; - - public java.math.BigInteger used() { - return used; - } - - @Override - public Statistics statistics() { - var rows = new java.util.ArrayList(); - if (canReceive()) rows.add(Statistics.rf("in", new Statistics.Counter(inserted, 20))); - if (canExtract()) rows.add(Statistics.rf("out", new Statistics.Counter(extracted, 20))); - if (canReceive()) rows.add(Statistics.transfer("in_max", maxInsert)); - if (canExtract()) rows.add(Statistics.transfer("out_max", maxExtract)); - rows.add(Statistics.rf("used", new Statistics.Counter(used, 20)).whenUsed(this)); - rows.add(Statistics.rf("used_max", new Statistics.MaximumUse(this)).whenUsed(this)); - return new Statistics(Statistics.label("energy"), rows); - } - - /** Runtime transfer counters. Loading, resizing and direct assignments are not transfers. */ - public java.math.BigInteger inserted() { - return inserted; - } - - public java.math.BigInteger extracted() { - return extracted; - } - - /** Captures stored energy and transfer counters for a synchronous transaction rollback. */ - public Runnable checkpoint() { - long before = stored; - var beforeIn = inserted; - var beforeOut = extracted; - var beforeUsed = used; - return () -> { - inserted = beforeIn; - extracted = beforeOut; - used = beforeUsed; - setStored(before); - }; - } - - private long stored; - private long capacity; - private long maxInsert; - private long maxExtract; - private Runnable onChange = () -> {}; - - /** - * An empty buffer. Negative values are treated as zero. - * - * @param capacity the most energy it holds - * @param maxInsert the most automation may insert per call - * @param maxExtract the most automation may extract per call - */ - public EnergyBuffer(long capacity, long maxInsert, long maxExtract) { - this.capacity = Math.max(0, capacity); - this.maxInsert = Math.max(0, maxInsert); - this.maxExtract = Math.max(0, maxExtract); - } - - /** An empty buffer with the same insert and extract rate. */ - public EnergyBuffer(long capacity, long maxTransfer) { - this(capacity, maxTransfer, maxTransfer); - } - - /** Runs after every change, typically the block entity's {@code setChanged}. */ - public EnergyBuffer onChange(Runnable onChange) { - this.onChange = onChange; - return this; - } - - /** The energy stored, exact. */ - public long stored() { - return stored; - } - - /** The most energy the buffer holds. */ - public long capacity() { - return capacity; - } - - /** How much more energy fits. */ - public long space() { - return capacity - stored; - } - - /** Sets the stored energy, clamped to the capacity. */ - public void setStored(long energy) { - long clamped = Math.clamp(energy, 0, capacity); - if (clamped != stored) { - stored = clamped; - onChange.run(); + private final ChangeSignal changes = new ChangeSignal(); + + /** Observe content/capacity changes without replacing the persistence callback. */ + public ChangeSignal changes() { + return changes; + } + + private java.math.BigInteger inserted = java.math.BigInteger.ZERO; + private java.math.BigInteger extracted = java.math.BigInteger.ZERO; + private java.math.BigInteger used = java.math.BigInteger.ZERO; + + public java.math.BigInteger used() { + return used; } - } - - /** Sets the capacity, e.g. after an upgrade; stored energy above it is lost. */ - public void setCapacity(long capacity) { - this.capacity = Math.max(0, capacity); - setStored(stored); - } - - /** - * Sets the per-call limits for automation; the owner's {@link #insert} and {@link #extract} - * ignore them. - */ - public void setRates(long maxInsert, long maxExtract) { - this.maxInsert = Math.max(0, maxInsert); - this.maxExtract = Math.max(0, maxExtract); - } - - /** Adds up to {@code amount}, ignoring the transfer rate. Returns the amount added. */ - public long insert(long amount, boolean simulate) { - long accepted = Math.clamp(amount, 0, space()); - if (!simulate && accepted > 0) { - inserted = inserted.add(java.math.BigInteger.valueOf(accepted)); - setStored(stored + accepted); + + @Override + public Statistics statistics() { + var rows = new java.util.ArrayList(); + if (canReceive()) + rows.add(Statistics.rf("in", new Statistics.Counter(inserted, 20))); + if (canExtract()) + rows.add(Statistics.rf("out", new Statistics.Counter(extracted, 20))); + if (canReceive()) + rows.add(Statistics.transfer("in_max", maxInsert)); + if (canExtract()) + rows.add(Statistics.transfer("out_max", maxExtract)); + rows.add(Statistics.rf("used", new Statistics.Counter(used, 20)).whenUsed(this)); + rows.add(Statistics.rf("used_max", new Statistics.MaximumUse(this)).whenUsed(this)); + return new Statistics(Statistics.label("energy"), rows); } - return accepted; - } - - /** Removes up to {@code amount}, ignoring the transfer rate. Returns the amount removed. */ - public long extract(long amount, boolean simulate) { - long removed = Math.clamp(amount, 0, stored); - if (!simulate && removed > 0) { - extracted = extracted.add(java.math.BigInteger.valueOf(removed)); - setStored(stored - removed); + + /** Runtime transfer counters. Loading, resizing and direct assignments are not transfers. */ + public java.math.BigInteger inserted() { + return inserted; } - return removed; - } - - /** Removes exactly {@code amount} if that much is stored, e.g. to run one tick of work. */ - public boolean consume(long amount) { - if (amount < 0 || amount > stored) return false; - used = used.add(java.math.BigInteger.valueOf(amount)); - setStored(stored - amount); - return true; - } - - /** Escrows a transfer charge without counting it as processor use or external output. */ - public boolean reserve(long amount) { - if (amount < 0 || amount > stored) return false; - setStored(stored - amount); - return true; - } - - /** Returns unused transfer escrow without counting it as external input. */ - public long refund(long amount) { - long accepted = Math.clamp(amount, 0, space()); - setStored(stored + accepted); - return accepted; - } - - @Override - public int receiveEnergy(int toReceive, boolean simulate) { - return (int) insert(Math.min(toReceive, maxInsert), simulate); - } - - @Override - public int extractEnergy(int toExtract, boolean simulate) { - return (int) extract(Math.min(toExtract, maxExtract), simulate); - } - - @Override - public int getEnergyStored() { - return (int) Math.min(stored, Integer.MAX_VALUE); - } - - @Override - public int getMaxEnergyStored() { - return (int) Math.min(capacity, Integer.MAX_VALUE); - } - - @Override - public boolean canReceive() { - return maxInsert > 0; - } - - @Override - public boolean canExtract() { - return maxExtract > 0; - } - - /** An automation view with a fixed mode, e.g. {@link IoMode#BOTH} for unsided access. */ - public IEnergyStorage view(IoMode mode) { - return view(() -> mode); - } - - /** - * An automation view whose mode is read on every call, so it follows side-configuration changes - * and rotation without invalidating capabilities. - */ - public IEnergyStorage view(Supplier mode) { - return new IEnergyStorage() { - @Override - public int receiveEnergy(int toReceive, boolean simulate) { - return mode.get().canInsert() ? EnergyBuffer.this.receiveEnergy(toReceive, simulate) : 0; - } - - @Override - public int extractEnergy(int toExtract, boolean simulate) { - return mode.get().canExtract() ? EnergyBuffer.this.extractEnergy(toExtract, simulate) : 0; - } - - @Override - public int getEnergyStored() { - return EnergyBuffer.this.getEnergyStored(); - } - - @Override - public int getMaxEnergyStored() { - return EnergyBuffer.this.getMaxEnergyStored(); - } - - @Override - public boolean canReceive() { - return mode.get().canInsert() && EnergyBuffer.this.canReceive(); - } - - @Override - public boolean canExtract() { - return mode.get().canExtract() && EnergyBuffer.this.canExtract(); - } + + public java.math.BigInteger extracted() { + return extracted; + } + + /** Captures stored energy and transfer counters for a synchronous transaction rollback. */ + public Runnable checkpoint() { + long before = stored; + var beforeIn = inserted; + var beforeOut = extracted; + var beforeUsed = used; + return () -> { + inserted = beforeIn; + extracted = beforeOut; + used = beforeUsed; + setStored(before); + }; + } + + private long stored; + private long capacity; + private long maxInsert; + private long maxExtract; + private Runnable onChange = () -> { }; - } - @Override - public Tag serializeNBT(HolderLookup.Provider provider) { - return LongTag.valueOf(stored); - } + /** + * An empty buffer. Negative values are treated as zero. + * + * @param capacity + * the most energy it holds + * @param maxInsert + * the most automation may insert per call + * @param maxExtract + * the most automation may extract per call + */ + public EnergyBuffer(long capacity, long maxInsert, long maxExtract) { + this.capacity = Math.max(0, capacity); + this.maxInsert = Math.max(0, maxInsert); + this.maxExtract = Math.max(0, maxExtract); + } + + /** An empty buffer with the same insert and extract rate. */ + public EnergyBuffer(long capacity, long maxTransfer) { + this(capacity, maxTransfer, maxTransfer); + } + + /** Runs after every change, typically the block entity's {@code setChanged}. */ + public EnergyBuffer onChange(Runnable onChange) { + this.onChange = onChange; + return this; + } + + /** The energy stored, exact. */ + public long stored() { + return stored; + } + + /** The most energy the buffer holds. */ + public long capacity() { + return capacity; + } + + /** How much more energy fits. */ + public long space() { + return capacity - stored; + } + + /** Sets the stored energy, clamped to the capacity. */ + public void setStored(long energy) { + long clamped = Math.clamp(energy, 0, capacity); + if (clamped != stored) { + stored = clamped; + changes.changed(); + onChange.run(); + } + } + + /** Sets the capacity, e.g. after an upgrade; stored energy above it is lost. */ + public void setCapacity(long capacity) { + long next = Math.max(0, capacity); + if (this.capacity == next) + return; + this.capacity = next; + setStored(stored); + changes.changed(); + } + + /** + * Sets the per-call limits for automation; the owner's {@link #insert} and {@link #extract} + * ignore them. + */ + public void setRates(long maxInsert, long maxExtract) { + this.maxInsert = Math.max(0, maxInsert); + this.maxExtract = Math.max(0, maxExtract); + } + + /** Adds up to {@code amount}, ignoring the transfer rate. Returns the amount added. */ + public long insert(long amount, boolean simulate) { + long accepted = Math.clamp(amount, 0, space()); + if (!simulate && accepted > 0) { + inserted = inserted.add(java.math.BigInteger.valueOf(accepted)); + setStored(stored + accepted); + } + return accepted; + } + + /** Removes up to {@code amount}, ignoring the transfer rate. Returns the amount removed. */ + public long extract(long amount, boolean simulate) { + long removed = Math.clamp(amount, 0, stored); + if (!simulate && removed > 0) { + extracted = extracted.add(java.math.BigInteger.valueOf(removed)); + setStored(stored - removed); + } + return removed; + } + + /** Removes exactly {@code amount} if that much is stored, e.g. to run one tick of work. */ + public boolean consume(long amount) { + if (amount < 0 || amount > stored) + return false; + used = used.add(java.math.BigInteger.valueOf(amount)); + setStored(stored - amount); + return true; + } + + /** Escrows a transfer charge without counting it as processor use or external output. */ + public boolean reserve(long amount) { + if (amount < 0 || amount > stored) + return false; + setStored(stored - amount); + return true; + } - @Override - public void deserializeNBT(HolderLookup.Provider provider, Tag tag) { - if (tag instanceof NumericTag number) stored = Math.clamp(number.getAsLong(), 0, capacity); - } + /** Returns unused transfer escrow without counting it as external input. */ + public long refund(long amount) { + long accepted = Math.clamp(amount, 0, space()); + setStored(stored + accepted); + return accepted; + } + + @Override + public int receiveEnergy(int toReceive, boolean simulate) { + return (int) insert(Math.min(toReceive, maxInsert), simulate); + } + + @Override + public int extractEnergy(int toExtract, boolean simulate) { + return (int) extract(Math.min(toExtract, maxExtract), simulate); + } + + @Override + public int getEnergyStored() { + return (int) Math.min(stored, Integer.MAX_VALUE); + } + + @Override + public int getMaxEnergyStored() { + return (int) Math.min(capacity, Integer.MAX_VALUE); + } + + @Override + public boolean canReceive() { + return maxInsert > 0; + } + + @Override + public boolean canExtract() { + return maxExtract > 0; + } + + /** An automation view with a fixed mode, e.g. {@link IoMode#BOTH} for unsided access. */ + public IEnergyStorage view(IoMode mode) { + return view(() -> mode); + } + + /** + * An automation view whose mode is read on every call, so it follows side-configuration changes + * and rotation without invalidating capabilities. + */ + public IEnergyStorage view(Supplier mode) { + return new IEnergyStorage() { + @Override + public int receiveEnergy(int toReceive, boolean simulate) { + return mode.get().canInsert() + ? EnergyBuffer.this.receiveEnergy(toReceive, simulate) + : 0; + } + + @Override + public int extractEnergy(int toExtract, boolean simulate) { + return mode.get().canExtract() + ? EnergyBuffer.this.extractEnergy(toExtract, simulate) + : 0; + } + + @Override + public int getEnergyStored() { + return EnergyBuffer.this.getEnergyStored(); + } + + @Override + public int getMaxEnergyStored() { + return EnergyBuffer.this.getMaxEnergyStored(); + } + + @Override + public boolean canReceive() { + return mode.get().canInsert() && EnergyBuffer.this.canReceive(); + } + + @Override + public boolean canExtract() { + return mode.get().canExtract() && EnergyBuffer.this.canExtract(); + } + }; + } + + @Override + public Tag serializeNBT(HolderLookup.Provider provider) { + return LongTag.valueOf(stored); + } + + @Override + public void deserializeNBT(HolderLookup.Provider provider, Tag tag) { + if (tag instanceof NumericTag number) { + long next = Math.clamp(number.getAsLong(), 0, capacity); + if (next != stored) { + stored = next; + changes.changed(); + } + } + } } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/FaceMask.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/FaceMask.java new file mode 100644 index 00000000..8d9a6745 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/FaceMask.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.lib.api.capabilities; + +import java.util.Set; + +/** Bounded face-policy encoding shared by runtime configuration and UI snapshots. */ +public final class FaceMask { + public static final int ALL = 63; + private FaceMask() { + } + public static int of(Set faces) { + int mask = 0; + for (var face : Set.copyOf(faces)) + mask |= 1 << face.ordinal(); + return mask; + } + public static int checked(int mask) { + if (mask < 0 || mask > ALL) + throw new IllegalArgumentException("Invalid face mask"); + return mask; + } + public static boolean available(int disabled, int face) { + return face >= 0 && face < 6 && (disabled & (1 << face)) == 0; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java index 1988f5b4..a2f653d6 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/FluidStorage.java @@ -18,6 +18,7 @@ import net.neoforged.neoforge.fluids.capability.IFluidHandler; import net.neoforged.neoforge.fluids.capability.templates.FluidTank; +import zone.moddev.mc.lib.api.util.ChangeSignal; import zone.moddev.mc.lib.machine.inventory.ReservationPlans; import zone.moddev.mc.lib.machine.inventory.TankPlan; import zone.moddev.mc.lib.machine.neoforge.FluidStacks; @@ -27,408 +28,442 @@ * filter, and a change callback. The owning block entity works on {@link #tank} directly, which * ignores roles; players' buckets and automation use the handler or a {@link #view}. * - *

Filling picks the first tank that already holds the fluid, then the first empty tank that - * accepts it. Draining picks the first tank holding the requested fluid, or for an amount, the - * first tank that is not empty. + *

+ * Filling picks the first tank that already holds the fluid, then the first empty tank that accepts + * it. Draining picks the first tank holding the requested fluid, or for an amount, the first tank + * that is not empty. */ public class FluidStorage implements IFluidHandler, INBTSerializable { - private final List tanks = new ArrayList<>(); - private final List roles = new ArrayList<>(); - private final List tankIds = new ArrayList<>(); - private final List> insertionRules = new ArrayList<>(); - private final ListTag recovery = new ListTag(); - private final ReservationPlans plans = - new ReservationPlans<>( - 0, - TankPlan.NONE, - TankPlan::plus, - TankPlan::minus, - (tank, plan) -> - plan.legal( - FluidStacks.amount(tanks.get(tank).getFluid()), tanks.get(tank).getCapacity())); - private Runnable onChange = () -> {}; - private long revision; - - /** Change counter for recipe search invalidation. */ - public long revision() { - return revision + plans.revision(); - } - - /** A storage with no tanks; add them with {@link #addTank}. */ - public FluidStorage() {} - - /** Runs after every change, typically the block entity's {@code setChanged}. */ - public FluidStorage onChange(Runnable onChange) { - this.onChange = onChange; - return this; - } - - /** Adds a tank and returns its index. */ - public int addTank(int capacity, Role role, Predicate filter) { - int index = tanks.size(); - tanks.add( - new FluidTank(capacity, filter) { - @Override - public void setFluid(FluidStack stack) { - super.setFluid(stack); - onContentsChanged(); - } - - @Override - public FluidTank setCapacity(int capacity) { - revision++; - return super.setCapacity(capacity); - } - - @Override - public FluidTank setValidator(Predicate validator) { - revision++; - return super.setValidator(validator); - } - - @Override - protected void onContentsChanged() { - revision++; - onChange.run(); - } - - @Override - public int fill(FluidStack resource, FluidAction action) { - var plan = plans.get(index); - if (plan.isNone()) return super.fill(resource, action); - int room = - plan.room( - FluidStacks.amount(getFluid()), FluidStacks.amount(resource), getCapacity()); - if (room <= 0) return 0; - int amount = Math.min(resource.getAmount(), room); - return super.fill(resource.copyWithAmount(amount), action); - } - - @Override - public FluidStack drain(int maxDrain, FluidAction action) { - var plan = plans.get(index); - if (plan.isNone()) return super.drain(maxDrain, action); - int free = plan.free(FluidStacks.amount(getFluid())); - return free <= 0 ? FluidStack.EMPTY : super.drain(Math.min(maxDrain, free), action); - } - }); - roles.add(role); - tankIds.add(null); - insertionRules.add(fluid -> true); - plans.add(); - revision++; - return tanks.size() - 1; - } - - /** Adds a tank that accepts any fluid and returns its index. */ - public int addTank(int capacity, Role role) { - return addTank(capacity, role, fluid -> true); - } - - /** - * The tank itself, for the machine's own work; it ignores roles but keeps its filter and obeys - * its plan. - */ - public FluidTank tank(int index) { - return tanks.get(index); - } - - /** Stable save identity; saves without identities continue to load by index. */ - public FluidStorage tankId(int tank, String id) { - if (!inRange(tank) || id == null || id.isBlank()) - throw new IllegalArgumentException("Invalid tank ID: " + id); - for (int i = 0; i < tankIds.size(); i++) - if (i != tank && id.equals(tankIds.get(i))) - throw new IllegalArgumentException("Duplicate tank ID: " + id); - tankIds.set(tank, id); - return this; - } - - /** Additional external insertion rule; machine products use fillResult and bypass this rule. */ - public FluidStorage restrict(Predicate rule, int... indexes) { - for (int tank : indexes) - if (!inRange(tank)) - throw new IndexOutOfBoundsException("Tank " + tank + " not in [0," + getTanks() + ")"); - for (int tank : indexes) insertionRules.set(tank, insertionRules.get(tank).and(rule)); - revision++; - return this; - } - - /** Machine product insertion, retaining the tank's structural filter and reservation. */ - public int fillResult(int tank, FluidStack fluid, FluidAction action) { - return tank(tank).fill(fluid, action); - } - - /** Unchecked restoration for save loading and transaction rollback. */ - public void restoreFluid(int tank, FluidStack fluid) { - tank(tank).setFluid(fluid.copy()); - } - - public ListTag recoveryContents() { - return recovery.copy(); - } - - /** Explicitly discards contents, displaced fluids and plans. Stop owning processors first. */ - public void clear() { - recovery.clear(); - for (int tank = 0; tank < getTanks(); tank++) { - setPlan(tank, TankPlan.NONE); - restoreFluid(tank, FluidStack.EMPTY); - } - revision++; - onChange.run(); - } - - public void recover(Consumer receiver) { - StorageRecovery.handoff(recovery, "mmdlib:fluids", "fluid", receiver); - revision++; - onChange.run(); - } - - /** Shared plan table, including transaction tracking for groups over this storage. */ - public ReservationPlans reservationPlans() { - return plans; - } - - /** What the machine plans for tank {@code tank}. */ - public TankPlan plan(int tank) { - return plans.get(tank); - } - - /** Replaces the plan with no checks, for undoing and loading; use {@link #reserve} otherwise. */ - public void setPlan(int tank, TankPlan plan) { - plans.set(tank, plan); - } - - /** - * Adds {@code plan} to what is planned for the tank. Returns false and changes nothing if the - * plans cannot be merged or the result is not legal for what the tank holds now. - */ - public boolean reserve(int tank, TankPlan plan) { - return plans.reserve(tank, plan); - } - - /** Takes {@code plan}, which must have been reserved, out of the tank's plan. */ - public void release(int tank, TankPlan plan) { - plans.release(tank, plan); - } - - /** The role of tank {@code tank}. */ - public Role role(int tank) { - return roles.get(tank); - } - - @Override - public int getTanks() { - return tanks.size(); - } - - @Override - public FluidStack getFluidInTank(int tank) { - return inRange(tank) ? tanks.get(tank).getFluid() : FluidStack.EMPTY; - } - - @Override - public int getTankCapacity(int tank) { - return inRange(tank) ? tanks.get(tank).getCapacity() : 0; - } - - @Override - public boolean isFluidValid(int tank, FluidStack stack) { - return inRange(tank) - && roles.get(tank) != Role.OUTPUT - && insertionRules.get(tank).test(stack) - && tanks.get(tank).isFluidValid(stack); - } - - @Override - public int fill(FluidStack resource, FluidAction action) { - return fill(resource, action, tank -> roles.get(tank) != Role.OUTPUT); - } - - @Override - public FluidStack drain(FluidStack resource, FluidAction action) { - return drain(resource, action, role -> true); - } - - @Override - public FluidStack drain(int maxDrain, FluidAction action) { - return drain(maxDrain, action, role -> true); - } - - /** An automation view with a fixed mode, e.g. {@link IoMode#BOTH} for unsided access. */ - public IFluidHandler view(IoMode mode) { - return view(() -> mode); - } - - /** - * An automation view whose mode is read on every call, so it follows side-configuration changes - * and rotation without invalidating capabilities. Every view shows all tanks with the same - * numbering. - */ - public IFluidHandler view(Supplier mode) { - return view(tank -> mode.get()); - } - - /** Per-tank permissions; filling/draining searches only eligible tanks without renumbering. */ - public IFluidHandler view(IntFunction mode) { - return new IFluidHandler() { - @Override - public int getTanks() { - return FluidStorage.this.getTanks(); - } - - @Override - public FluidStack getFluidInTank(int tank) { - return FluidStorage.this.getFluidInTank(tank); - } - - @Override - public int getTankCapacity(int tank) { - return FluidStorage.this.getTankCapacity(tank); - } - - @Override - public boolean isFluidValid(int tank, FluidStack stack) { - return inRange(tank) - && mode.apply(tank).canInsert() - && roles.get(tank).canInsert() - && FluidStorage.this.isFluidValid(tank, stack); - } - - @Override - public int fill(FluidStack resource, FluidAction action) { - return FluidStorage.this.fill( - resource, action, tank -> roles.get(tank).canInsert() && mode.apply(tank).canInsert()); - } - - @Override - public FluidStack drain(FluidStack resource, FluidAction action) { - return FluidStorage.this.drain( - resource, - action, - tank -> roles.get(tank).canExtract() && mode.apply(tank).canExtract()); - } - - @Override - public FluidStack drain(int maxDrain, FluidAction action) { - return FluidStorage.this.drain( - maxDrain, - action, - tank -> roles.get(tank).canExtract() && mode.apply(tank).canExtract()); - } + private final ChangeSignal changes = new ChangeSignal(); + + /** Observe content/capacity changes without replacing the persistence callback. */ + public ChangeSignal changes() { + return changes; + } + + private final List tanks = new ArrayList<>(); + private final List roles = new ArrayList<>(); + private final List tankIds = new ArrayList<>(); + private final List> insertionRules = new ArrayList<>(); + private final ListTag recovery = new ListTag(); + private final ReservationPlans plans = new ReservationPlans<>( + 0, + TankPlan.NONE, + TankPlan::plus, + TankPlan::minus, + (tank, plan) -> plan.legal( + FluidStacks.amount(tanks.get(tank).getFluid()), tanks.get(tank).getCapacity())); + private Runnable onChange = () -> { }; - } - - private int fill(FluidStack resource, FluidAction action, IntPredicate allowed) { - if (resource.isEmpty()) return 0; - int target = -1; - for (int i = 0; i < tanks.size() && target < 0; i++) - if (allowed.test(i) - && insertionRules.get(i).test(resource) - && FluidStack.isSameFluidSameComponents(tanks.get(i).getFluid(), resource) - && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) target = i; - for (int i = 0; i < tanks.size() && target < 0; i++) - if (allowed.test(i) - && insertionRules.get(i).test(resource) - && tanks.get(i).isEmpty() - && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) target = i; - return target < 0 ? 0 : tanks.get(target).fill(resource, action); - } - - private FluidStack drain(FluidStack resource, FluidAction action, IntPredicate allowed) { - if (resource.isEmpty()) return FluidStack.EMPTY; - for (int i = 0; i < tanks.size(); i++) - if (allowed.test(i) - && FluidStack.isSameFluidSameComponents(tanks.get(i).getFluid(), resource) - && !tanks.get(i).drain(resource, FluidAction.SIMULATE).isEmpty()) - return tanks.get(i).drain(resource, action); - return FluidStack.EMPTY; - } - - private FluidStack drain(int maxDrain, FluidAction action, IntPredicate allowed) { - for (int i = 0; i < tanks.size(); i++) - if (allowed.test(i) && !tanks.get(i).drain(maxDrain, FluidAction.SIMULATE).isEmpty()) - return tanks.get(i).drain(maxDrain, action); - return FluidStack.EMPTY; - } - - private boolean inRange(int tank) { - return tank >= 0 && tank < tanks.size(); - } - - @Override - public CompoundTag serializeNBT(HolderLookup.Provider provider) { - ListTag list = new ListTag(); - for (int i = 0; i < tanks.size(); i++) { - var entry = tanks.get(i).writeToNBT(provider, new CompoundTag()); - if (tankIds.get(i) != null) entry.putString("TankId", tankIds.get(i)); - list.add(entry); - } - CompoundTag tag = new CompoundTag(); - tag.put("Tanks", list); - tag.put("Recovery", recovery.copy()); - return tag; - } - - /** Loads by stable identity or legacy index, preserving removed tanks and capacity overflow. */ - @Override - public void deserializeNBT(HolderLookup.Provider provider, CompoundTag tag) { - deserializeNBT(provider, tag, java.util.function.IntUnaryOperator.identity()); - } - - /** Explicit index migration for pre-identity saves. The host selects this by its save version. */ - public void deserializeNBT( - HolderLookup.Provider provider, - CompoundTag tag, - java.util.function.IntUnaryOperator legacyTanks) { - var saved = tag.getList("Tanks", Tag.TAG_COMPOUND); - var list = new ListTag(); - for (int i = 0; i < saved.size(); i++) { - var tank = saved.getCompound(i); - var entry = tank.getCompound("Fluid").copy(); - if (entry.isEmpty()) continue; - entry.putInt("Tank", i); - if (tank.contains("TankId")) entry.putString("TankId", tank.getString("TankId")); - list.add(entry); - } - list.addAll(tag.getList("Recovery", Tag.TAG_COMPOUND).copy()); - recovery.clear(); - for (var tank : tanks) tank.setFluid(FluidStack.EMPTY); - for (var value : list) { - var entry = (CompoundTag) value; - int index = - entry.contains("TankId") - ? tankIds.indexOf(entry.getString("TankId")) - : legacyTanks.applyAsInt(entry.getInt("Tank")); - if (!entry.contains("TankId")) { - entry.putInt("Tank", index); - if (inRange(index) && tankIds.get(index) != null) - entry.putString("TankId", tankIds.get(index)); - } - var fluid = FluidStack.parseOptional(provider, entry); - if (fluid.isEmpty()) { - if (entry.contains("id")) recovery.add(entry.copy()); - continue; - } - if (!inRange(index) - || !tanks.get(index).isFluidValid(fluid) - || (!tanks.get(index).isEmpty() - && !FluidStack.isSameFluidSameComponents(tanks.get(index).getFluid(), fluid))) { - recovery.add(entry.copy()); - continue; - } - int held = tanks.get(index).getFluidAmount(); - int accepted = - Math.min(fluid.getAmount(), Math.max(0, tanks.get(index).getCapacity() - held)); - restoreFluid(index, fluid.copyWithAmount(held + accepted)); - if (accepted < fluid.getAmount()) { - var extra = entry.copy(); - extra.putInt("amount", fluid.getAmount() - accepted); - recovery.add(extra); - } - } - revision++; - onChange.run(); - } + private long revision; + + /** Change counter for recipe search invalidation. */ + public long revision() { + return revision + plans.revision(); + } + + /** A storage with no tanks; add them with {@link #addTank}. */ + public FluidStorage() { + } + + /** Runs after every change, typically the block entity's {@code setChanged}. */ + public FluidStorage onChange(Runnable onChange) { + this.onChange = onChange; + return this; + } + + /** Adds a tank and returns its index. */ + public int addTank(int capacity, Role role, Predicate filter) { + int index = tanks.size(); + tanks.add( + new FluidTank(capacity, filter) { + @Override + public void setFluid(FluidStack stack) { + super.setFluid(stack); + onContentsChanged(); + } + + @Override + public FluidTank setCapacity(int capacity) { + revision++; + var result = super.setCapacity(capacity); + changes.changed(); + return result; + } + + @Override + public FluidTank setValidator(Predicate validator) { + revision++; + return super.setValidator(validator); + } + + @Override + protected void onContentsChanged() { + revision++; + changes.changed(); + onChange.run(); + } + + @Override + public int fill(FluidStack resource, FluidAction action) { + var plan = plans.get(index); + if (plan.isNone()) + return super.fill(resource, action); + int room = plan.room( + FluidStacks.amount(getFluid()), FluidStacks.amount(resource), + getCapacity()); + if (room <= 0) + return 0; + int amount = Math.min(resource.getAmount(), room); + return super.fill(resource.copyWithAmount(amount), action); + } + + @Override + public FluidStack drain(int maxDrain, FluidAction action) { + var plan = plans.get(index); + if (plan.isNone()) + return super.drain(maxDrain, action); + int free = plan.free(FluidStacks.amount(getFluid())); + return free <= 0 + ? FluidStack.EMPTY + : super.drain(Math.min(maxDrain, free), action); + } + }); + roles.add(role); + tankIds.add(null); + insertionRules.add(fluid -> true); + plans.add(); + revision++; + return tanks.size() - 1; + } + + /** Adds a tank that accepts any fluid and returns its index. */ + public int addTank(int capacity, Role role) { + return addTank(capacity, role, fluid -> true); + } + + /** + * The tank itself, for the machine's own work; it ignores roles but keeps its filter and obeys + * its plan. + */ + public FluidTank tank(int index) { + return tanks.get(index); + } + + /** Stable save identity; saves without identities continue to load by index. */ + public FluidStorage tankId(int tank, String id) { + if (!inRange(tank) || id == null || id.isBlank()) + throw new IllegalArgumentException("Invalid tank ID: " + id); + for (int i = 0; i < tankIds.size(); i++) + if (i != tank && id.equals(tankIds.get(i))) + throw new IllegalArgumentException("Duplicate tank ID: " + id); + tankIds.set(tank, id); + return this; + } + + /** Additional external insertion rule; machine products use fillResult and bypass this rule. */ + public FluidStorage restrict(Predicate rule, int... indexes) { + for (int tank : indexes) + if (!inRange(tank)) + throw new IndexOutOfBoundsException( + "Tank " + tank + " not in [0," + getTanks() + ")"); + for (int tank : indexes) + insertionRules.set(tank, insertionRules.get(tank).and(rule)); + revision++; + return this; + } + + /** Machine product insertion, retaining the tank's structural filter and reservation. */ + public int fillResult(int tank, FluidStack fluid, FluidAction action) { + return tank(tank).fill(fluid, action); + } + + /** Unchecked restoration for save loading and transaction rollback. */ + public void restoreFluid(int tank, FluidStack fluid) { + tank(tank).setFluid(fluid.copy()); + } + + public ListTag recoveryContents() { + return recovery.copy(); + } + + /** Explicitly discards contents, displaced fluids and plans. Stop owning processors first. */ + public void clear() { + recovery.clear(); + for (int tank = 0; tank < getTanks(); tank++) { + setPlan(tank, TankPlan.NONE); + restoreFluid(tank, FluidStack.EMPTY); + } + revision++; + changes.changed(); + onChange.run(); + } + + public void recover(Consumer receiver) { + StorageRecovery.handoff(recovery, "mmdlib:fluids", "fluid", receiver); + revision++; + changes.changed(); + onChange.run(); + } + + /** Shared plan table, including transaction tracking for groups over this storage. */ + public ReservationPlans reservationPlans() { + return plans; + } + + /** What the machine plans for tank {@code tank}. */ + public TankPlan plan(int tank) { + return plans.get(tank); + } + + /** + * Replaces the plan with no checks, for undoing and loading; use {@link #reserve} otherwise. + */ + public void setPlan(int tank, TankPlan plan) { + plans.set(tank, plan); + } + + /** + * Adds {@code plan} to what is planned for the tank. Returns false and changes nothing if the + * plans cannot be merged or the result is not legal for what the tank holds now. + */ + public boolean reserve(int tank, TankPlan plan) { + return plans.reserve(tank, plan); + } + + /** Takes {@code plan}, which must have been reserved, out of the tank's plan. */ + public void release(int tank, TankPlan plan) { + plans.release(tank, plan); + } + + /** The role of tank {@code tank}. */ + public Role role(int tank) { + return roles.get(tank); + } + + @Override + public int getTanks() { + return tanks.size(); + } + + @Override + public FluidStack getFluidInTank(int tank) { + return inRange(tank) ? tanks.get(tank).getFluid() : FluidStack.EMPTY; + } + + @Override + public int getTankCapacity(int tank) { + return inRange(tank) ? tanks.get(tank).getCapacity() : 0; + } + + @Override + public boolean isFluidValid(int tank, FluidStack stack) { + return inRange(tank) + && roles.get(tank) != Role.OUTPUT + && insertionRules.get(tank).test(stack) + && tanks.get(tank).isFluidValid(stack); + } + + @Override + public int fill(FluidStack resource, FluidAction action) { + return fill(resource, action, tank -> roles.get(tank) != Role.OUTPUT); + } + + @Override + public FluidStack drain(FluidStack resource, FluidAction action) { + return drain(resource, action, role -> true); + } + + @Override + public FluidStack drain(int maxDrain, FluidAction action) { + return drain(maxDrain, action, role -> true); + } + + /** An automation view with a fixed mode, e.g. {@link IoMode#BOTH} for unsided access. */ + public IFluidHandler view(IoMode mode) { + return view(() -> mode); + } + + /** + * An automation view whose mode is read on every call, so it follows side-configuration changes + * and rotation without invalidating capabilities. Every view shows all tanks with the same + * numbering. + */ + public IFluidHandler view(Supplier mode) { + return view(tank -> mode.get()); + } + + /** Per-tank permissions; filling/draining searches only eligible tanks without renumbering. */ + public IFluidHandler view(IntFunction mode) { + return new IFluidHandler() { + @Override + public int getTanks() { + return FluidStorage.this.getTanks(); + } + + @Override + public FluidStack getFluidInTank(int tank) { + return FluidStorage.this.getFluidInTank(tank); + } + + @Override + public int getTankCapacity(int tank) { + return FluidStorage.this.getTankCapacity(tank); + } + + @Override + public boolean isFluidValid(int tank, FluidStack stack) { + return inRange(tank) + && mode.apply(tank).canInsert() + && roles.get(tank).canInsert() + && FluidStorage.this.isFluidValid(tank, stack); + } + + @Override + public int fill(FluidStack resource, FluidAction action) { + return FluidStorage.this.fill( + resource, action, + tank -> roles.get(tank).canInsert() && mode.apply(tank).canInsert()); + } + + @Override + public FluidStack drain(FluidStack resource, FluidAction action) { + return FluidStorage.this.drain( + resource, + action, + tank -> roles.get(tank).canExtract() && mode.apply(tank).canExtract()); + } + + @Override + public FluidStack drain(int maxDrain, FluidAction action) { + return FluidStorage.this.drain( + maxDrain, + action, + tank -> roles.get(tank).canExtract() && mode.apply(tank).canExtract()); + } + }; + } + + private int fill(FluidStack resource, FluidAction action, IntPredicate allowed) { + if (resource.isEmpty()) + return 0; + int target = -1; + for (int i = 0; i < tanks.size() && target < 0; i++) + if (allowed.test(i) + && insertionRules.get(i).test(resource) + && FluidStack.isSameFluidSameComponents(tanks.get(i).getFluid(), resource) + && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) + target = i; + for (int i = 0; i < tanks.size() && target < 0; i++) + if (allowed.test(i) + && insertionRules.get(i).test(resource) + && tanks.get(i).isEmpty() + && tanks.get(i).fill(resource, FluidAction.SIMULATE) > 0) + target = i; + return target < 0 ? 0 : tanks.get(target).fill(resource, action); + } + + private FluidStack drain(FluidStack resource, FluidAction action, IntPredicate allowed) { + if (resource.isEmpty()) + return FluidStack.EMPTY; + for (int i = 0; i < tanks.size(); i++) + if (allowed.test(i) + && FluidStack.isSameFluidSameComponents(tanks.get(i).getFluid(), resource) + && !tanks.get(i).drain(resource, FluidAction.SIMULATE).isEmpty()) + return tanks.get(i).drain(resource, action); + return FluidStack.EMPTY; + } + + private FluidStack drain(int maxDrain, FluidAction action, IntPredicate allowed) { + for (int i = 0; i < tanks.size(); i++) + if (allowed.test(i) && !tanks.get(i).drain(maxDrain, FluidAction.SIMULATE).isEmpty()) + return tanks.get(i).drain(maxDrain, action); + return FluidStack.EMPTY; + } + + private boolean inRange(int tank) { + return tank >= 0 && tank < tanks.size(); + } + + @Override + public CompoundTag serializeNBT(HolderLookup.Provider provider) { + ListTag list = new ListTag(); + for (int i = 0; i < tanks.size(); i++) { + var entry = tanks.get(i).writeToNBT(provider, new CompoundTag()); + if (tankIds.get(i) != null) + entry.putString("TankId", tankIds.get(i)); + list.add(entry); + } + CompoundTag tag = new CompoundTag(); + tag.put("Tanks", list); + tag.put("Recovery", recovery.copy()); + return tag; + } + + /** Loads by stable identity or legacy index, preserving removed tanks and capacity overflow. */ + @Override + public void deserializeNBT(HolderLookup.Provider provider, CompoundTag tag) { + deserializeNBT(provider, tag, java.util.function.IntUnaryOperator.identity()); + } + + /** + * Explicit index migration for pre-identity saves. The host selects this by its save version. + */ + public void deserializeNBT( + HolderLookup.Provider provider, + CompoundTag tag, + java.util.function.IntUnaryOperator legacyTanks) { + var saved = tag.getList("Tanks", Tag.TAG_COMPOUND); + var list = new ListTag(); + for (int i = 0; i < saved.size(); i++) { + var tank = saved.getCompound(i); + var entry = tank.getCompound("Fluid").copy(); + if (entry.isEmpty()) + continue; + entry.putInt("Tank", i); + if (tank.contains("TankId")) + entry.putString("TankId", tank.getString("TankId")); + list.add(entry); + } + list.addAll(tag.getList("Recovery", Tag.TAG_COMPOUND).copy()); + recovery.clear(); + for (var tank : tanks) + tank.setFluid(FluidStack.EMPTY); + for (var value : list) { + var entry = (CompoundTag) value; + int index = entry.contains("TankId") + ? tankIds.indexOf(entry.getString("TankId")) + : legacyTanks.applyAsInt(entry.getInt("Tank")); + if (!entry.contains("TankId")) { + entry.putInt("Tank", index); + if (inRange(index) && tankIds.get(index) != null) + entry.putString("TankId", tankIds.get(index)); + } + var fluid = FluidStack.parseOptional(provider, entry); + if (fluid.isEmpty()) { + if (entry.contains("id")) + recovery.add(entry.copy()); + continue; + } + if (!inRange(index) + || !tanks.get(index).isFluidValid(fluid) + || (!tanks.get(index).isEmpty() + && !FluidStack.isSameFluidSameComponents(tanks.get(index).getFluid(), fluid))) { + recovery.add(entry.copy()); + continue; + } + int held = tanks.get(index).getFluidAmount(); + int accepted = Math.min(fluid.getAmount(), + Math.max(0, tanks.get(index).getCapacity() - held)); + restoreFluid(index, fluid.copyWithAmount(held + accepted)); + if (accepted < fluid.getAmount()) { + var extra = entry.copy(); + extra.putInt("amount", fluid.getAmount() - accepted); + recovery.add(extra); + } + } + revision++; + changes.changed(); + onChange.run(); + } } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java index 33375e91..be58e547 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/ItemStorage.java @@ -19,6 +19,8 @@ import net.neoforged.neoforge.items.IItemHandler; import net.neoforged.neoforge.items.ItemStackHandler; +import zone.moddev.mc.lib.api.util.ChangeSignal; + /** * A machine inventory: an {@link ItemStackHandler} with a {@link Role}, filter and limit per slot, * and a change callback. The owning block entity and its screen use it directly; automation gets a @@ -30,6 +32,13 @@ * {@link #insertResult}. */ public class ItemStorage extends PlannedItemHandler implements ItemStore { + private final ChangeSignal changes = new ChangeSignal(); + + /** Observe content/capacity changes without replacing the persistence callback. */ + public ChangeSignal changes() { + return changes; + } + @Override public ItemStorage storage(int slot) { checked(slot); @@ -183,6 +192,7 @@ public long revision() { /** Reports an in-place change made by a menu, including persistence and recipe invalidation. */ public void markChanged() { revision++; + changes.changed(); onChange.run(); } @@ -236,6 +246,7 @@ public ItemStorage limit(int limit, int... slots) { revision++; for (int slot : slots) limits[checked(slot)] = limit; + changes.changed(); return this; } diff --git a/src/main/java/zone/moddev/mc/lib/api/capabilities/SideConfig.java b/src/main/java/zone/moddev/mc/lib/api/capabilities/SideConfig.java index 1ef8f1a5..851a6947 100644 --- a/src/main/java/zone/moddev/mc/lib/api/capabilities/SideConfig.java +++ b/src/main/java/zone/moddev/mc/lib/api/capabilities/SideConfig.java @@ -2,6 +2,7 @@ import java.util.EnumMap; import java.util.Map; +import java.util.Set; import com.mojang.serialization.Codec; @@ -14,63 +15,96 @@ * sees the same storage, and the mode only allows or refuses operations. */ public final class SideConfig { - /** - * A map from {@link RelativeFace} to {@link IoMode}; faces left out are {@link IoMode#DISABLED}. - */ - public static final Codec CODEC = - Codec.unboundedMap(RelativeFace.CODEC, IoMode.CODEC) - .xmap(SideConfig::new, config -> config.modes); - - private final EnumMap modes = new EnumMap<>(RelativeFace.class); - private Runnable onChange = () -> {}; - - /** Every face set to {@code mode}. */ - public SideConfig(IoMode mode) { - for (RelativeFace face : RelativeFace.values()) modes.put(face, mode); - } - - /** The given faces; any face left out is {@link IoMode#DISABLED}. */ - public SideConfig(Map modes) { - this(IoMode.DISABLED); - this.modes.putAll(modes); - } - - /** Runs after every change, typically the block entity's {@code setChanged}. */ - public SideConfig onChange(Runnable onChange) { - this.onChange = onChange; - return this; - } - - /** The mode of {@code face}. */ - public IoMode get(RelativeFace face) { - return modes.get(face); - } - - /** Sets the mode of {@code face}, running the change callback if it changed. */ - public SideConfig set(RelativeFace face, IoMode mode) { - if (modes.put(face, mode) != mode) onChange.run(); - return this; - } - - /** Advances one face to its next mode, as a side-configuration button does. */ - public IoMode cycle(RelativeFace face) { - IoMode next = get(face).next(); - set(face, next); - return next; - } - - /** The mode on world side {@code side}; a null side (internal access) is {@link IoMode#BOTH}. */ - public IoMode mode(Direction facing, Direction side) { - return side == null ? IoMode.BOTH : get(RelativeFace.of(facing, side)); - } - - /** As {@link #mode(Direction, Direction)}, taking the front from the block state. */ - public IoMode mode(BlockState state, Direction side) { - return mode(RelativeFace.facing(state), side); - } - - /** Replaces every mode with {@code other}'s, e.g. when loading; does not run the callback. */ - public void copyFrom(SideConfig other) { - modes.putAll(other.modes); - } + /** + * A map from {@link RelativeFace} to {@link IoMode}; faces left out are + * {@link IoMode#DISABLED}. + */ + public static final Codec CODEC = Codec + .unboundedMap(RelativeFace.CODEC, IoMode.CODEC) + .xmap(SideConfig::new, config -> config.modes); + + private final EnumMap modes = new EnumMap<>(RelativeFace.class); + private int disabledFaces; + private Runnable onChange = () -> { + }; + + /** Every face set to {@code mode}. */ + public SideConfig(IoMode mode) { + for (RelativeFace face : RelativeFace.values()) + modes.put(face, mode); + } + + /** The given faces; any face left out is {@link IoMode#DISABLED}. */ + public SideConfig(Map modes) { + this(IoMode.DISABLED); + this.modes.putAll(modes); + } + + /** Runs after every change, typically the block entity's {@code setChanged}. */ + public SideConfig onChange(Runnable onChange) { + this.onChange = onChange; + return this; + } + + /** Machine policy, independent of saved player modes. Re-enabled faces stay disabled. */ + public SideConfig disabledFaces(Set faces) { + int next = FaceMask.of(faces); + if (next != disabledFaces) { + disabledFaces = next; + for (var face : RelativeFace.values()) + if (!faceAvailable(face)) + modes.put(face, IoMode.DISABLED); + onChange.run(); + } + return this; + } + + public int disabledFaceMask() { + return disabledFaces; + } + + public boolean faceAvailable(RelativeFace face) { + return FaceMask.available(disabledFaces, face.ordinal()); + } + + /** The mode of {@code face}. */ + public IoMode get(RelativeFace face) { + return faceAvailable(face) ? modes.get(face) : IoMode.DISABLED; + } + + /** Sets the mode of {@code face}, running the change callback if it changed. */ + public SideConfig set(RelativeFace face, IoMode mode) { + if (!faceAvailable(face)) + return this; + if (modes.put(face, mode) != mode) + onChange.run(); + return this; + } + + /** Advances one face to its next mode, as a side-configuration button does. */ + public IoMode cycle(RelativeFace face) { + if (!faceAvailable(face)) + return IoMode.DISABLED; + IoMode next = get(face).next(); + set(face, next); + return next; + } + + /** + * The mode on world side {@code side}; a null side (internal access) is {@link IoMode#BOTH}. + */ + public IoMode mode(Direction facing, Direction side) { + return side == null ? IoMode.BOTH : get(RelativeFace.of(facing, side)); + } + + /** As {@link #mode(Direction, Direction)}, taking the front from the block state. */ + public IoMode mode(BlockState state, Direction side) { + return mode(RelativeFace.facing(state), side); + } + + /** Replaces every mode with {@code other}'s, e.g. when loading; does not run the callback. */ + public void copyFrom(SideConfig other) { + for (var face : RelativeFace.values()) + modes.put(face, faceAvailable(face) ? other.get(face) : IoMode.DISABLED); + } } diff --git a/src/main/java/zone/moddev/mc/lib/api/redstone/RedstoneControl.java b/src/main/java/zone/moddev/mc/lib/api/redstone/RedstoneControl.java index 8ac811c1..e5923aff 100644 --- a/src/main/java/zone/moddev/mc/lib/api/redstone/RedstoneControl.java +++ b/src/main/java/zone/moddev/mc/lib/api/redstone/RedstoneControl.java @@ -5,6 +5,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.function.ToDoubleFunction; import net.minecraft.core.Direction; @@ -17,6 +18,7 @@ import org.jetbrains.annotations.Nullable; import zone.moddev.mc.lib.api.automation.ResourceGroup; +import zone.moddev.mc.lib.api.capabilities.FaceMask; import zone.moddev.mc.lib.api.capabilities.RelativeFace; /** Persistent per-face signal intervals and outputs discovered from machine parts. */ @@ -40,6 +42,7 @@ public enum Mode { private final int[] sourceIndexes = new int[6]; private final int[] sampledLevels = new int[6]; private long revision; + private int disabledFaces; // The first tick repairs only adjacent dust, without notifying unrelated neighbours. private int connections = -1; private boolean paused, legacyAny; @@ -75,6 +78,50 @@ public RedstoneControl(BlockEntity owner, Collection parts) { Arrays.fill(sourceIndexes, -1); } + /** Machine policy; not restored from player configuration saves. */ + public RedstoneControl disabledFaces(Set faces) { + int next = FaceMask.of(faces); + if (next != disabledFaces) { + disabledFaces = next; + normalizeFaces(); + revision++; + owner.setChanged(); + readInputs(); + updateOutputs(); + var level = owner.getLevel(); + if (level != null && !level.isClientSide) + level.sendBlockUpdated(owner.getBlockPos(), owner.getBlockState(), + owner.getBlockState(), Block.UPDATE_CLIENTS); + } + return this; + } + public int disabledFaceMask() { + return disabledFaces; + } + public boolean faceAvailable(RelativeFace face) { + return faceAvailable(face.ordinal()); + } + public boolean faceAvailable(int face) { + return FaceMask.available(disabledFaces, face); + } + private void normalizeFaces() { + for (int face = 0; face < 6; face++) + if (!faceAvailable(face)) { + modes[face] = Mode.IGNORE.ordinal(); + incoming[face] = 0; + } + } + /** World updates include policy; disk saves deliberately do not. */ + public CompoundTag saveClient(HolderLookup.Provider registries) { + var tag = save(registries); + tag.putInt("disabled_faces", disabledFaces); + return tag; + } + public void loadClient(CompoundTag tag, HolderLookup.Provider registries) { + disabledFaces = FaceMask.checked(tag.getInt("disabled_faces")); + load(tag, registries); + } + public List outputs() { return outputs; } @@ -84,7 +131,7 @@ public long revision() { } public Mode mode(int face) { - return MODES[modes[face]]; + return faceAvailable(face) ? MODES[modes[face]] : Mode.IGNORE; } public boolean outside(int face) { @@ -96,6 +143,8 @@ public boolean inverted(int face) { } public void inverted(int face, boolean value) { + if (!faceAvailable(face)) + return; inverted[face] = value; changed(); } @@ -109,11 +158,11 @@ public int upper(int face) { } public int incoming(int face) { - return incoming[face]; + return faceAvailable(face) ? incoming[face] : 0; } public int outgoing(int face) { - return outgoing[face]; + return faceAvailable(face) ? outgoing[face] : 0; } public boolean paused() { @@ -136,17 +185,21 @@ public Direction world(int face) { } public void mode(int face, Mode mode) { + if (!faceAvailable(face)) + return; modes[face] = mode.ordinal(); changed(); } public void outside(int face, boolean value) { + if (!faceAvailable(face)) + return; outside[face] = value; changed(); } public boolean range(int face, int low, int high) { - if (low < 0 || high > 15 || low > high) + if (!faceAvailable(face) || low < 0 || high > 15 || low > high) return false; lower[face] = low; upper[face] = high; @@ -155,7 +208,7 @@ public boolean range(int face, int low, int high) { } public boolean source(int face, int index) { - if (index < 0 || index >= outputs.size()) + if (!faceAvailable(face) || index < 0 || index >= outputs.size()) return false; sourceIds[face] = outputs.get(index).id(); sourceIndexes[face] = index; @@ -188,6 +241,10 @@ public void readInputs() { paused = false; boolean powered = false; for (int face = 0; face < 6; face++) { + if (!faceAvailable(face)) { + incoming[face] = 0; + continue; + } var direction = world(face); var neighbour = owner.getBlockPos().relative(direction); incoming[face] = level.hasChunkAt(neighbour) @@ -198,7 +255,7 @@ public void readInputs() { paused |= matches(incoming[face], lower[face], upper[face], outside[face]); } if (legacyAny) - paused = !powered; + paused = disabledFaces != FaceMask.ALL && !powered; } /** @@ -264,9 +321,9 @@ public static int level(double fullness) { /** Block getSignal receives the direction from the receiver toward this block. */ public int signal(Direction queryDirection) { - return outgoing[RelativeFace + return outgoing(RelativeFace .of(RelativeFace.facing(owner.getBlockState()), queryDirection.getOpposite()) - .ordinal()]; + .ordinal()); } /** @@ -319,6 +376,7 @@ public void load(CompoundTag tag, HolderLookup.Provider registries) { .getString(tag.contains("source_" + i) ? "source_" + i : "group_" + i); sourceIndexes[i] = findSource(sourceIds[i]); } + normalizeFaces(); legacyAny = tag.getBoolean("legacy_any"); revision++; } @@ -332,6 +390,7 @@ public void migrate(int oldMode) { Arrays.fill(inverted, false); Arrays.fill(sourceIds, ""); Arrays.fill(sourceIndexes, -1); + normalizeFaces(); changed(); legacyAny = oldMode == 1; } diff --git a/src/main/java/zone/moddev/mc/lib/api/util/ChangeSignal.java b/src/main/java/zone/moddev/mc/lib/api/util/ChangeSignal.java new file mode 100644 index 00000000..950e44fa --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/api/util/ChangeSignal.java @@ -0,0 +1,31 @@ +package zone.moddev.mc.lib.api.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Instance-owned change notifications; subscribe, publish and unsubscribe on the owning thread. */ +public final class ChangeSignal { + private final List listeners = new ArrayList<>(); + + /** + * Returns an idempotent unsubscribe action. Does not replace the owner's persistence callback. + */ + public Runnable subscribe(Runnable listener) { + Objects.requireNonNull(listener); + // Wrap so two registrations of the same callback have independent lifetimes. + Runnable registered = new Runnable() { + @Override + public void run() { + listener.run(); + } + }; + listeners.add(registered); + return () -> listeners.remove(registered); + } + + public void changed() { + if (!listeners.isEmpty()) + List.copyOf(listeners).forEach(Runnable::run); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/JeiGhostRenderer.java b/src/main/java/zone/moddev/mc/lib/client/jei/JeiGhostRenderer.java new file mode 100644 index 00000000..9fdc2ef5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/client/jei/JeiGhostRenderer.java @@ -0,0 +1,102 @@ +package zone.moddev.mc.lib.client.jei; + +import java.util.List; +import java.util.function.BiConsumer; + +import com.mojang.blaze3d.systems.RenderSystem; + +import mezz.jei.api.gui.builder.ITooltipBuilder; +import mezz.jei.api.ingredients.IIngredientRenderer; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; + +import net.neoforged.neoforge.client.extensions.common.IClientFluidTypeExtensions; +import net.neoforged.neoforge.fluids.FluidStack; + +import org.jetbrains.annotations.Nullable; + +import org.joml.Vector3f; + +import zone.moddev.mc.lib.gui.client.GhostItems; + +/** Faded samples retain JEI's original ingredient tooltips and navigation. */ +final class JeiGhostRenderer implements IIngredientRenderer { + private final IIngredientRenderer delegate; + private final BiConsumer draw; + + private JeiGhostRenderer(IIngredientRenderer delegate, BiConsumer draw) { + this.delegate = delegate; + this.draw = draw; + } + + static IIngredientRenderer items(IIngredientRenderer delegate) { + var ghosts = new GhostItems(); + return new JeiGhostRenderer<>(delegate, (graphics, stack) -> { + ghosts.render(graphics, stack, 0, 0); + graphics.renderItemDecorations(Minecraft.getInstance().font, stack, 0, 0); + }); + } + + static IIngredientRenderer fluids(IIngredientRenderer delegate, + long capacity) { + return new JeiGhostRenderer<>(delegate, (graphics, fluid) -> { + if (fluid.isEmpty()) + return; + int height = (int) Math.max(1, Math.min(16, 16L * fluid.getAmount() / capacity)); + var extensions = IClientFluidTypeExtensions.of(fluid.getFluid()); + var sprite = Minecraft.getInstance().getTextureAtlas(InventoryMenu.BLOCK_ATLAS) + .apply(extensions.getStillTexture(fluid)); + int tint = extensions.getTintColor(fluid); + graphics.flush(); + graphics.setColor((tint >> 16 & 255) / 255f, (tint >> 8 & 255) / 255f, + (tint & 255) / 255f, (tint >>> 24) / 255f * .5f); + // GuiGraphics scissor coordinates are screen-relative, whereas JEI translates the + // pose into each slot. Convert both corners before clipping the bottom-filled sprite. + var matrix = graphics.pose().last().pose(); + var top = matrix.transformPosition(new Vector3f(0, 16 - height, 0)); + var bottom = matrix.transformPosition(new Vector3f(16, 16, 0)); + graphics.enableScissor((int) Math.floor(top.x()), (int) Math.floor(top.y()), + (int) Math.ceil(bottom.x()), (int) Math.ceil(bottom.y())); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + try { + graphics.blit(0, 0, 0, 16, 16, sprite); + graphics.flush(); + } finally { + graphics.disableScissor(); + graphics.setColor(1, 1, 1, 1); + RenderSystem.disableBlend(); + } + }); + } + + @Override + public void render(GuiGraphics graphics, T ingredient) { + draw.accept(graphics, ingredient); + } + + @Override + @SuppressWarnings("removal") + public List getTooltip(T ingredient, TooltipFlag flag) { + return delegate.getTooltip(ingredient, flag); + } + + @Override + public List getTooltip(T ingredient, Item.TooltipContext context, + @Nullable Player player, TooltipFlag flag) { + return delegate.getTooltip(ingredient, context, player, flag); + } + + @Override + public void getTooltip(ITooltipBuilder tooltip, T ingredient, Item.TooltipContext context, + @Nullable Player player, TooltipFlag flag) { + delegate.getTooltip(tooltip, ingredient, context, player, flag); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java index bb13cf30..99ba3721 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MMDLibJeiPlugin.java @@ -2,16 +2,16 @@ import java.util.List; -import net.minecraft.client.Minecraft; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.resources.ResourceLocation; - import mezz.jei.api.IModPlugin; import mezz.jei.api.JeiPlugin; import mezz.jei.api.registration.IRecipeCatalystRegistration; import mezz.jei.api.registration.IRecipeCategoryRegistration; import mezz.jei.api.registration.IRecipeRegistration; +import net.minecraft.client.Minecraft; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; + import zone.moddev.mc.lib.MMDLib; import zone.moddev.mc.lib.api.MaterialEventTypes; import zone.moddev.mc.lib.common.recipes.CrushingRecipe; @@ -25,107 +25,109 @@ */ @JeiPlugin public final class MMDLibJeiPlugin implements IModPlugin { - /** Called by JEI, which finds the plugin through {@link JeiPlugin}. */ - public MMDLibJeiPlugin() {} + /** Called by JEI, which finds the plugin through {@link JeiPlugin}. */ + public MMDLibJeiPlugin() { + } - @Override - public void registerGuiHandlers(mezz.jei.api.registration.IGuiHandlerRegistration registration) { - MachineJei.gui(registration); - } + @Override + public void registerGuiHandlers( + mezz.jei.api.registration.IGuiHandlerRegistration registration) { + MachineJei.gui(registration); + } - @Override - public void registerRecipeTransferHandlers( - mezz.jei.api.registration.IRecipeTransferRegistration registration) { - MachineJei.transfer(registration); - } + @Override + public void registerRecipeTransferHandlers( + mezz.jei.api.registration.IRecipeTransferRegistration registration) { + MachineJei.transfer(registration); + } - @Override - public void onRuntimeAvailable(mezz.jei.api.runtime.IJeiRuntime runtime) { - MachineJei.runtime(runtime); - } + @Override + public void onRuntimeAvailable(mezz.jei.api.runtime.IJeiRuntime runtime) { + MachineJei.runtime(runtime); + } - @Override - public void onRuntimeUnavailable() { - MachineJei.unavailable(); - } + @Override + public void onRuntimeUnavailable() { + MachineJei.unavailable(); + } - @Override - public ResourceLocation getPluginUid() { - return ResourceLocation.parse("mmdlib:jei"); - } + @Override + public ResourceLocation getPluginUid() { + return ResourceLocation.parse("mmdlib:jei"); + } - @Override - public void registerCategories(IRecipeCategoryRegistration registration) { - registration.addRecipeCategories( - new CrushingCategory(registration.getJeiHelpers().getGuiHelper())); - var level = Minecraft.getInstance().level; - for (var type : machineRecipeTypes()) - registration.addRecipeCategories( - MachineRecipeCategory.create( - type, - level == null - ? List.of() - : MachineRecipeCategory.shown(type.recipes(level.getRecipeManager())))); - } + @Override + public void registerCategories(IRecipeCategoryRegistration registration) { + registration.addRecipeCategories( + new CrushingCategory(registration.getJeiHelpers().getGuiHelper())); + var level = Minecraft.getInstance().level; + for (var type : machineRecipeTypes()) + registration.addRecipeCategories( + MachineRecipeCategory.create( + type, + level == null + ? List.of() + : MachineRecipeCategory.shown(type.recipes(level.getRecipeManager())), + registration.getJeiHelpers())); + } - @Override - public void registerRecipes(IRecipeRegistration registration) { - var level = Minecraft.getInstance().level; - registration.addRecipes( - CrushingCategory.TYPE, - level == null - ? List.of() - : level.getRecipeManager().getAllRecipesFor(CrushingRecipe.TYPE.get())); - if (level != null) - for (var type : machineRecipeTypes()) + @Override + public void registerRecipes(IRecipeRegistration registration) { + var level = Minecraft.getInstance().level; registration.addRecipes( - MachineRecipeCategory.recipeType(type), - MachineRecipeCategory.shown(type.recipes(level.getRecipeManager()))); - } + CrushingCategory.TYPE, + level == null + ? List.of() + : level.getRecipeManager().getAllRecipesFor(CrushingRecipe.TYPE.get())); + if (level != null) + for (var type : machineRecipeTypes()) + registration.addRecipes( + MachineRecipeCategory.recipeType(type), + MachineRecipeCategory.shown(type.recipes(level.getRecipeManager()))); + } - /** Every registered machine recipe type, whichever mod registered it. */ - private static List machineRecipeTypes() { - return BuiltInRegistries.RECIPE_TYPE.stream() - .filter(MachineRecipeType.class::isInstance) - .map(MachineRecipeType.class::cast) - .toList(); - } + /** Every registered machine recipe type, whichever mod registered it. */ + private static List machineRecipeTypes() { + return BuiltInRegistries.RECIPE_TYPE.stream() + .filter(MachineRecipeType.class::isInstance) + .map(MachineRecipeType.class::cast) + .toList(); + } - /** - * Catalysts are found by binding, not class: any item bound to a crackhammer handler, addon items - * included. - */ - @Override - public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) { - zone.moddev.mc.lib.machine.neoforge.MachineContent.definitions() - .forEach( - (id, machine) -> - registration.addRecipeCatalyst( + /** + * Catalysts are found by binding, not class: any item bound to a crackhammer handler, addon + * items included. + */ + @Override + public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) { + zone.moddev.mc.lib.machine.neoforge.MachineContent.definitions() + .forEach( + (id, machine) -> registration.addRecipeCatalyst( new net.minecraft.world.item.ItemStack(BuiltInRegistries.ITEM.get(id)), MachineRecipeCategory.recipeType( zone.moddev.mc.lib.machine.neoforge.MachineContent.recipeType( machine.recipeType().id())))); - var materials = MMDLib.itemMaterials(); - MMDLib.itemDefinitions() - .forEach( - (id, definition) -> { - var material = materials.get(id); - boolean crackhammer = - CrackhammerEvents.CRUSH.equals( - ItemEventBridge.binding( - definition.events(), - material, - MaterialEventTypes.TOOL_RIGHT_CLICK.name())) - || CrackhammerEvents.HARVEST.equals( - ItemEventBridge.binding( - definition.events(), - material, - MaterialEventTypes.TOOL_HARVEST.name())); - if (crackhammer) - BuiltInRegistries.ITEM - .getOptional(id) - .ifPresent( - item -> registration.addRecipeCatalysts(CrushingCategory.TYPE, item)); - }); - } + var materials = MMDLib.itemMaterials(); + MMDLib.itemDefinitions() + .forEach( + (id, definition) -> { + var material = materials.get(id); + boolean crackhammer = CrackhammerEvents.CRUSH.equals( + ItemEventBridge.binding( + definition.events(), + material, + MaterialEventTypes.TOOL_RIGHT_CLICK.name())) + || CrackhammerEvents.HARVEST.equals( + ItemEventBridge.binding( + definition.events(), + material, + MaterialEventTypes.TOOL_HARVEST.name())); + if (crackhammer) + BuiltInRegistries.ITEM + .getOptional(id) + .ifPresent( + item -> registration.addRecipeCatalysts(CrushingCategory.TYPE, + item)); + }); + } } diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java index 81278165..0f948bd8 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java @@ -28,15 +28,23 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.MenuType; import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.RecipeHolder; + +import net.neoforged.neoforge.network.PacketDistributor; import zone.moddev.mc.lib.MMDLib; +import zone.moddev.mc.lib.api.capabilities.ItemInventories; import zone.moddev.mc.lib.gui.client.IngredientDrops; import zone.moddev.mc.lib.gui.client.MachineScreen; import zone.moddev.mc.lib.gui.layout.Rect; import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.elements.SlotElement; import zone.moddev.mc.lib.gui.runtime.MachineMenu; import zone.moddev.mc.lib.gui.runtime.ScreenHost; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; /** Loaded exclusively by the optional JEI plugin. */ final class MachineJei { @@ -200,20 +208,62 @@ public IRecipeTransferError transferRecipe( for (var id : categories) { var type = runtime.getRecipeManager().getRecipeType(id); if (type.isPresent() - && !host.screenRecipeItems(id).isEmpty() && runtime .getRecipeManager() .createRecipeLookup(type.get()) .get() - .anyMatch(recipe::equals)) + .anyMatch(recipe::equals)) { + if (host.screenRecipeItems(id).isEmpty()) + return selectGhost(helper, menu, recipe, transfer); return delegate(type.get(), helper, menu, recipe, slots, player, max, transfer); + } } return helper.createInternalError(); } }); } + /** A single ghost sample selects a recipe without transferring its items or fluids. */ + private static IRecipeTransferError selectGhost(IRecipeTransferHandlerHelper helper, + MachineMenu menu, Object recipe, boolean transfer) { + if (!(recipe instanceof RecipeHolder holder) + || !(holder.value() instanceof MachineRecipeEntry entry)) + return helper.createInternalError(); + SlotElement target = null; + ItemStack sample = ItemStack.EMPTY; + for (var group : entry.recipe().inputs().entrySet()) + for (var part : group.getValue()) { + if (!(part instanceof MachineIngredient.ItemPart item)) + continue; + // Multiple real or ghost ingredients need an inventory transfer, not a selector. + if (target != null) + return helper.createInternalError(); + int index = menu.menuSlot(new ItemInventories.Address(group.getKey(), 0)); + var element = menu.slotElement(index); + if (element == null || !element.ghost() || !menu.visible(element) + || element.selectionAction().isEmpty() || menu.elementPending(element)) + return helper.createInternalError(); + var choices = item.items().getItems(); + if (choices.length == 0) + return helper.createInternalError(); + sample = choices[0].copyWithCount(1); + if (!element.acceptsSelection(0, sample)) + return helper.createInternalError(); + target = element; + } + if (target == null) + return helper.createInternalError(); + if (transfer) { + var packet = menu.prepareElementAction(target, target.selectionAction().orElseThrow(), + target.selection(0, sample)); + if (packet.isEmpty()) + return helper.createInternalError(); + PacketDistributor.sendToServer(packet.get()); + } + return null; + } + @SuppressWarnings("removal") private static IRecipeTransferError delegate( RecipeType type, diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java index 901f0ed6..cbe3e30b 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeCategory.java @@ -1,28 +1,38 @@ package zone.moddev.mc.lib.client.jei; import java.text.NumberFormat; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; +import java.util.Set; -import net.minecraft.client.Minecraft; -import net.minecraft.core.Holder; -import net.minecraft.core.HolderSet; -import net.minecraft.network.chat.Component; -import net.minecraft.world.item.crafting.RecipeHolder; -import net.minecraft.world.level.material.Fluid; - +import mezz.jei.api.constants.VanillaTypes; import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; import mezz.jei.api.gui.builder.IRecipeSlotBuilder; import mezz.jei.api.gui.widgets.IRecipeExtrasBuilder; +import mezz.jei.api.helpers.IJeiHelpers; +import mezz.jei.api.ingredients.IIngredientRenderer; +import mezz.jei.api.neoforge.NeoForgeTypes; import mezz.jei.api.recipe.IFocusGroup; import mezz.jei.api.recipe.RecipeIngredientRole; import mezz.jei.api.recipe.RecipeType; import mezz.jei.api.recipe.category.AbstractRecipeCategory; +import mezz.jei.api.runtime.IIngredientManager; +import net.minecraft.client.Minecraft; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.material.Fluid; + +import zone.moddev.mc.lib.machine.definition.MachineDefinition; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.neoforge.MachineContent; import zone.moddev.mc.lib.machine.recipe.MachineProduct; import zone.moddev.mc.lib.machine.recipe.MachineRecipe; import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; @@ -36,188 +46,240 @@ * as one whose input is an empty tag from an optional mod, are left out. */ public final class MachineRecipeCategory - extends AbstractRecipeCategory> { - private static final int CELL = 18, COLUMNS = 3, ARROW = 24, GAP = 4, TEXT = 10; - private static final int TEXT_COLOR = 0xFF808080; + extends + AbstractRecipeCategory> { + private static final int CELL = MachineRecipeLayout.CELL, TEXT = 10; + private static final int TEXT_COLOR = 0xFF808080; - private final int inputColumns, rows, contentX; + private final int rows; + private final MachineRecipeLayout layout; + private final Set ghostInputs; + private final IIngredientManager ingredients; + private final IIngredientRenderer ghostItems; - private MachineRecipeCategory( - MachineRecipeType type, int inputColumns, int outputColumns, int rows, int textWidth) { - super( - recipeType(type), - Component.translatableWithFallback( + private MachineRecipeCategory(MachineRecipeType type, int width, MachineRecipeLayout layout, + Set ghostInputs, IJeiHelpers helpers) { + super(recipeType(type), Component.translatableWithFallback( "jei." + type.id().getNamespace() + "." + type.id().getPath(), type.id().toString()), - null, - Math.max(inputColumns * CELL + GAP + ARROW + GAP + outputColumns * CELL, textWidth), - rows * CELL + TEXT); - this.inputColumns = inputColumns; - this.rows = rows; - contentX = (getWidth() - (inputColumns * CELL + GAP + ARROW + GAP + outputColumns * CELL)) / 2; - } - - /** The JEI recipe type of {@code type}, for catalysts and recipe lookups. */ - public static RecipeType> recipeType(MachineRecipeType type) { - return RecipeType.createRecipeHolderType(type.id()); - } - - /** The recipes to show: those whose every input names at least one item or fluid. */ - static List> shown( - Collection> recipes) { - return recipes.stream() - .filter( - holder -> - holder.value().recipe().inputs().values().stream() + null, width, layout.rows() * CELL + TEXT); + this.layout = layout; + this.rows = layout.rows(); + this.ghostInputs = ghostInputs; + ingredients = helpers.getIngredientManager(); + ghostItems = JeiGhostRenderer + .items(ingredients.getIngredientRenderer(VanillaTypes.ITEM_STACK)); + } + + /** The JEI recipe type of {@code type}, for catalysts and recipe lookups. */ + public static RecipeType> recipeType(MachineRecipeType type) { + return RecipeType.createRecipeHolderType(type.id()); + } + + /** The recipes to show: those whose every input names at least one item or fluid. */ + static List> shown( + Collection> recipes) { + return recipes.stream() + .filter( + holder -> holder.value().recipe().inputs().values().stream() .flatMap(List::stream) .allMatch( - part -> - switch (part) { - case MachineIngredient.ItemPart items -> !items.items().hasNoItems(); - case MachineIngredient.FluidPart fluids -> fluids.fluids().size() > 0; - case MachineIngredient.EnergyPart energy -> true; - })) - .toList(); - } - - /** A category for {@code type}, wide and tall enough for every recipe in {@code recipes}. */ - static MachineRecipeCategory create( - MachineRecipeType type, Collection> recipes) { - int inputs = 1, outputs = 1, textWidth = 0; - for (var holder : recipes) { - inputs = Math.max(inputs, inputSlots(holder.value().recipe())); - outputs = Math.max(outputs, outputSlots(holder.value().recipe())); - textWidth = - Math.max(textWidth, Minecraft.getInstance().font.width(details(holder.value().recipe()))); + part -> switch (part) { + case MachineIngredient.ItemPart items -> !items.items().hasNoItems(); + case MachineIngredient.FluidPart fluids -> fluids.fluids().size() > 0; + case MachineIngredient.EnergyPart energy -> true; + })) + .toList(); } - int inputColumns = Math.min(COLUMNS, inputs), outputColumns = Math.min(COLUMNS, outputs); - int rows = Math.max(ceil(inputs, inputColumns), ceil(outputs, outputColumns)); - return new MachineRecipeCategory(type, inputColumns, outputColumns, rows, textWidth); - } - - @Override - public void setRecipe( - IRecipeLayoutBuilder builder, RecipeHolder holder, IFocusGroup focuses) { - var recipe = holder.value().recipe(); - int index = 0; - for (var parts : recipe.inputs().values()) - for (var part : parts) { - switch (part) { - case MachineIngredient.ItemPart items -> - slot(builder, RecipeIngredientRole.INPUT, contentX, index++) - .addItemStacks( - Arrays.stream(items.items().getItems()) - .map(stack -> stack.copyWithCount(items.count())) - .toList()); - case MachineIngredient.FluidPart fluids -> { - var slot = slot(builder, RecipeIngredientRole.INPUT, contentX, index++); - for (var fluid : sources(fluids.fluids())) - slot.addFluidStack(fluid.value(), fluids.millibuckets()); - slot.setFluidRenderer(fluids.millibuckets(), false, 16, 16); - } - case MachineIngredient.EnergyPart energy -> {} - } - } - int outputX = contentX + inputColumns * CELL + GAP + ARROW + GAP; - index = 0; - for (var products : recipe.outputs().values()) - for (var product : products) { - var slot = slot(builder, RecipeIngredientRole.OUTPUT, outputX, index++); - switch (product) { - case MachineProduct.ItemProduct item -> slot.addItemStack(item.stack()); - case MachineProduct.FluidProduct fluid -> { - slot.addFluidStack( - fluid.fluid().fluid().value(), fluid.fluid().amount(), fluid.fluid().components()); - slot.setFluidRenderer(fluid.fluid().amount(), false, 16, 16); - } + + /** A category for {@code type}, wide and tall enough for every recipe in {@code recipes}. */ + static MachineRecipeCategory create(MachineRecipeType type, + Collection> recipes, IJeiHelpers helpers) { + var ghosts = MachineDefinition.ghostInputsFor(MachineContent.definitions().values(), + type.id()); + int width = 1; + var layout = new MachineRecipeLayout(0, 0, 0); + for (var holder : recipes) { + var recipe = holder.value().recipe(); + layout = layout.merge(layout(recipe, ghosts)); + width = Math.max(width, Minecraft.getInstance().font.width(details(recipe))); } - if (product.chance() < 1) - slot.addRichTooltipCallback( - (view, tooltip) -> - tooltip.add( - Component.translatable( - "jei.mmdlib.machine.chance", percent(product.chance())))); - } - } - - @Override - public void createRecipeExtras( - IRecipeExtrasBuilder builder, RecipeHolder holder, IFocusGroup focuses) { - var recipe = holder.value().recipe(); - builder - .addRecipeArrowWidget() - .setPosition(contentX + inputColumns * CELL + GAP, (rows * CELL - 17) / 2); - builder - .addText(details(recipe), getWidth(), TEXT) - .setPosition(0, rows * CELL + 1) - .setColor(TEXT_COLOR); - } - - private static Component details(MachineRecipe recipe) { - var lines = new ArrayList(); - long energy = - recipe.inputs().values().stream() + return new MachineRecipeCategory(type, Math.max(width, layout.width()), layout, ghosts, + helpers); + } + + private static boolean ghost(String group, MachineIngredient part, Set ghostInputs) { + return !part.consumed() + || part instanceof MachineIngredient.ItemPart && ghostInputs.contains(group); + } + + private static MachineRecipeLayout layout(MachineRecipe recipe, Set ghostInputs) { + int ghosts = 0, inputs = 0; + for (var entry : recipe.inputs().entrySet()) + for (var part : entry.getValue()) { + if (part instanceof MachineIngredient.EnergyPart) + continue; + if (ghost(entry.getKey(), part, ghostInputs)) + ghosts++; + else + inputs++; + } + return new MachineRecipeLayout(ghosts, inputs, outputSlots(recipe)); + } + + @Override + public void setRecipe( + IRecipeLayoutBuilder builder, RecipeHolder holder, + IFocusGroup focuses) { + var recipe = holder.value().recipe(); + var recipeLayout = layout(recipe, ghostInputs); + int originX = (getWidth() - layout.width()) / 2; + int ghostIndex = 0, inputIndex = 0; + for (var entry : recipe.inputs().entrySet()) + for (var part : entry.getValue()) { + if (part instanceof MachineIngredient.EnergyPart) + continue; + boolean ghost = ghost(entry.getKey(), part, ghostInputs); + boolean sample = part instanceof MachineIngredient.ItemPart + && ghostInputs.contains(entry.getKey()); + var role = sample + ? RecipeIngredientRole.RENDER_ONLY + : ghost ? RecipeIngredientRole.CATALYST : RecipeIngredientRole.INPUT; + var slot = slot(builder, role, originX + (ghost ? 0 : layout.inputX()), + ghost ? ghostIndex++ : inputIndex++, + ghost ? recipeLayout.ghosts() : recipeLayout.inputs()); + slot.setSlotName((sample ? "sample/" : ghost ? "catalyst/" : "input/") + + entry.getKey() + "/" + (ghost ? ghostIndex : inputIndex)); + switch (part) { + case MachineIngredient.ItemPart items -> { + slot.addItemStacks(Arrays.stream(items.items().getItems()) + .map(stack -> stack.copyWithCount(items.count())).toList()); + if (ghost) + slot.setCustomRenderer(VanillaTypes.ITEM_STACK, ghostItems); + } + case MachineIngredient.FluidPart fluids -> { + for (var fluid : sources(fluids.fluids())) + slot.addFluidStack(fluid.value(), fluids.millibuckets()); + long capacity = MachineRecipeLayout.fluidCapacity(fluids.millibuckets()); + if (ghost) { + slot.setCustomRenderer(NeoForgeTypes.FLUID_STACK, + JeiGhostRenderer.fluids( + ingredients.getIngredientRenderer(NeoForgeTypes.FLUID_STACK), + capacity)); + // JEI's default ingredient-list renderer does not promise an amount + // tooltip. A ghost still represents the full recipe requirement. + slot.addRichTooltipCallback((view, tooltip) -> tooltip.add( + Component.translatable("jei.mmdlib.machine.required_fluid", + NumberFormat.getIntegerInstance(Locale.ROOT) + .format(fluids.millibuckets())))); + } else + slot.setFluidRenderer(capacity, false, 16, 16); + } + case MachineIngredient.EnergyPart energy -> { + } + } + if (ghost) + slot.addRichTooltipCallback((view, tooltip) -> tooltip.add( + Component.translatable("jei.mmdlib.machine.not_consumed"))); + } + int outputX = originX + layout.outputX(); + int index = 0; + for (var products : recipe.outputs().values()) + for (var product : products) { + var slot = slot(builder, RecipeIngredientRole.OUTPUT, outputX, index++, + recipeLayout.outputs()); + switch (product) { + case MachineProduct.ItemProduct item -> slot.addItemStack(item.stack()); + case MachineProduct.FluidProduct fluid -> { + slot.addFluidStack( + fluid.fluid().fluid().value(), fluid.fluid().amount(), + fluid.fluid().components()); + slot.setFluidRenderer( + MachineRecipeLayout.fluidCapacity(fluid.fluid().amount()), false, 16, + 16); + } + } + if (product.chance() < 1) + slot.addRichTooltipCallback( + (view, tooltip) -> tooltip.add( + Component.translatable( + "jei.mmdlib.machine.chance", percent(product.chance())))); + } + } + + @Override + public void createRecipeExtras( + IRecipeExtrasBuilder builder, RecipeHolder holder, + IFocusGroup focuses) { + var recipe = holder.value().recipe(); + int originX = (getWidth() - layout.width()) / 2; + if (layout(recipe, ghostInputs).separator()) + builder.addText(Component.literal(":"), 4, TEXT) + .setPosition(originX + MachineRecipeLayout.columns(layout.ghosts()) * CELL + 4, + (rows * CELL - TEXT) / 2) + .setColor(TEXT_COLOR); + builder.addRecipeArrowWidget() + .setPosition(originX + layout.arrowX(), (rows * CELL - 17) / 2); + builder + .addText(details(recipe), getWidth(), TEXT) + .setPosition(0, rows * CELL + 1) + .setColor(TEXT_COLOR); + } + + private static Component details(MachineRecipe recipe) { + var lines = new ArrayList(); + long energy = recipe.inputs().values().stream() .flatMap(List::stream) .filter(MachineIngredient.EnergyPart.class::isInstance) .mapToLong(MachineIngredient::amount) .sum(); - if (energy > 0) - lines.add( - Component.translatable( - "jei.mmdlib.machine.energy", - NumberFormat.getIntegerInstance(Locale.ROOT).format(energy))); - lines.add(Component.translatable("jei.mmdlib.machine.time", seconds(recipe.time()))); - return joined(lines); - } - - private IRecipeSlotBuilder slot( - IRecipeLayoutBuilder builder, RecipeIngredientRole role, int originX, int index) { - return builder - .addSlot(role, originX + index % COLUMNS * CELL + 1, index / COLUMNS * CELL + 1) - .setStandardSlotBackground(); - } - - /** The source fluids of {@code fluids}, as a tag also lists flowing ones; all if none are. */ - private static List> sources(HolderSet fluids) { - var sources = - fluids.stream().filter(fluid -> fluid.value().isSource(fluid.value().defaultFluidState())); - var list = sources.toList(); - return list.isEmpty() ? fluids.stream().toList() : list; - } - - private static int inputSlots(MachineRecipe recipe) { - return (int) - recipe.inputs().values().stream() - .flatMap(List::stream) - .filter(part -> !(part instanceof MachineIngredient.EnergyPart)) - .count(); - } - - private static int outputSlots(MachineRecipe recipe) { - return recipe.outputs().values().stream().mapToInt(List::size).sum(); - } - - private static int ceil(int count, int columns) { - return Math.max(1, (count + columns - 1) / columns); - } - - private static String percent(float chance) { - var format = NumberFormat.getNumberInstance(Locale.ROOT); - format.setMaximumFractionDigits(1); - return format.format(chance * 100); - } - - private static String seconds(int ticks) { - var format = NumberFormat.getNumberInstance(Locale.ROOT); - format.setMaximumFractionDigits(2); - return format.format(ticks / 20.0); - } - - private static Component joined(List parts) { - var result = Component.empty(); - for (int i = 0; i < parts.size(); i++) { - if (i > 0) result.append(" · "); - result.append(parts.get(i)); + if (energy > 0) + lines.add( + Component.translatable( + "jei.mmdlib.machine.energy", + NumberFormat.getIntegerInstance(Locale.ROOT).format(energy))); + lines.add(Component.translatable("jei.mmdlib.machine.time", seconds(recipe.time()))); + return joined(lines); + } + + private IRecipeSlotBuilder slot(IRecipeLayoutBuilder builder, RecipeIngredientRole role, + int originX, int index, int count) { + int columns = MachineRecipeLayout.columns(count); + int top = (rows - MachineRecipeLayout.rows(count)) * CELL / 2; + return builder.addSlot(role, originX + index % columns * CELL + 1, + top + index / columns * CELL + 1).setStandardSlotBackground(); + } + + /** The source fluids of {@code fluids}, as a tag also lists flowing ones; all if none are. */ + private static List> sources(HolderSet fluids) { + var sources = fluids.stream() + .filter(fluid -> fluid.value().isSource(fluid.value().defaultFluidState())); + var list = sources.toList(); + return list.isEmpty() ? fluids.stream().toList() : list; + } + + private static int outputSlots(MachineRecipe recipe) { + return recipe.outputs().values().stream().mapToInt(List::size).sum(); + } + + private static String percent(float chance) { + var format = NumberFormat.getNumberInstance(Locale.ROOT); + format.setMaximumFractionDigits(1); + return format.format(chance * 100); + } + + private static String seconds(int ticks) { + var format = NumberFormat.getNumberInstance(Locale.ROOT); + format.setMaximumFractionDigits(2); + return format.format(ticks / 20.0); + } + + private static Component joined(List parts) { + var result = Component.empty(); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) + result.append(" · "); + result.append(parts.get(i)); + } + return result; } - return result; - } } diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayout.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayout.java new file mode 100644 index 00000000..58f652be --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayout.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.lib.client.jei; + +/** Geometry shared by recipe slots and extras, independent of the client renderer. */ +record MachineRecipeLayout(int ghosts, int inputs, int outputs) { + static final int CELL = 18, COLUMNS = 3, ARROW = 24, GAP = 4, SEPARATOR = 12; + + /** Reserve each section's largest footprint so recipes in one category share columns. */ + MachineRecipeLayout merge(MachineRecipeLayout other) { + return new MachineRecipeLayout(Math.max(ghosts, other.ghosts), + Math.max(inputs, other.inputs), Math.max(outputs, other.outputs)); + } + + static int columns(int count) { + return Math.min(COLUMNS, count); + } + + static int rows(int count) { + return Math.ceilDiv(count, COLUMNS); + } + + int rows() { + return Math.max(1, Math.max(rows(ghosts), Math.max(rows(inputs), rows(outputs)))); + } + + boolean separator() { + return ghosts > 0 && inputs > 0; + } + + int inputX() { + return columns(ghosts) * CELL + (separator() ? SEPARATOR : 0); + } + + int arrowX() { + return inputX() + columns(inputs) * CELL + GAP; + } + + int outputX() { + return arrowX() + ARROW + GAP; + } + + int width() { + return outputX() + columns(outputs) * CELL; + } + + /** Whole buckets, rounded upward; use long arithmetic even for int-sized fluid amounts. */ + static long fluidCapacity(int amount) { + return Math.max(1000L, Math.ceilDiv((long) amount, 1000L) * 1000L); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java b/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java index 48371da5..12aaa377 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/BuiltinElementClients.java @@ -28,6 +28,7 @@ import static zone.moddev.mc.lib.gui.load.BuiltinElements.PROGRESS; import static zone.moddev.mc.lib.gui.load.BuiltinElements.RADIO; import static zone.moddev.mc.lib.gui.load.BuiltinElements.RADIO_GROUP; +import static zone.moddev.mc.lib.gui.load.BuiltinElements.RECIPE_SELECTOR; import static zone.moddev.mc.lib.gui.load.BuiltinElements.SIDE_CONFIG; import static zone.moddev.mc.lib.gui.load.BuiltinElements.SIDE_TAB; import static zone.moddev.mc.lib.gui.load.BuiltinElements.SLOT; @@ -113,6 +114,10 @@ private static List> declarations() { .renderer(ButtonRenderer::draw) .behavior(ButtonBehavior::new), ElementClient.of(LABEL).renderer(TextControlRenderer::label), + ElementClient.of(RECIPE_SELECTOR).renderer(RecipeSelectorBehavior::render) + .renderer(ElementRenderers.Pass.TOP, ElementRenderers.Clip.VIEWPORT, + RecipeSelectorBehavior::renderChooser) + .behavior(RecipeSelectorBehavior::new), ElementClient.of(CHECKBOX).renderer(TextControlRenderer::checkbox), ElementClient.of(RADIO).renderer(TextControlRenderer::radio) .behavior(RadioBehavior::new), diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java index 784da4af..1168274c 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementBehavior.java @@ -9,6 +9,15 @@ * cancels capture without committing. */ public interface ElementBehavior { + /** Optional transient chooser drawn above normal elements, outside the owner's layout box. */ + default java.util.Optional overlayBounds() { + return java.util.Optional.empty(); + } + + /** Outside clicks dismiss a transient chooser without reaching inventory slots underneath. */ + default void dismissOverlay() { + } + enum Click { PASS, CONSUME, CAPTURE } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java b/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java index eed9eb8e..12966e08 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ElementInput.java @@ -98,6 +98,12 @@ private void refreshHover() { /** Topmost visible hit without changing pointer or focus state. */ public E hit(double x, double y) { + var overlay = overlayOwner(); + if (overlay != null) + return hitArea.test(x, y) && (bounds.apply(overlay).contains(x, y) + || instances.get(overlay).overlayBounds().orElseThrow().contains(x, y)) + ? overlay + : null; if (!closed && hitArea.test(x, y)) for (var element : order.reversed()) if (isShown(element) && bounds.apply(element).contains(x, y)) @@ -105,6 +111,14 @@ public E hit(double x, double y) { return null; } + public E overlayOwner() { + if (!closed) + for (var element : order.reversed()) + if (isShown(element) && instances.get(element).overlayBounds().isPresent()) + return element; + return null; + } + private void changeHover(E next) { if (next == hovered) return; @@ -186,6 +200,14 @@ public boolean click(double x, double y, int button) { if (captured != null) return true; beginClick(); + var overlay = overlayOwner(); + if (overlay != null) { + if (hit(x, y) == overlay) + click(overlay, x, y, button); + else + instances.get(overlay).dismissOverlay(); + return true; + } if (hitArea.test(x, y)) for (var element : order.reversed()) { if (!isShown(element) || !bounds.apply(element).contains(x, y)) @@ -269,6 +291,11 @@ public boolean scroll(double x, double y, double dx, double dy) { return false; if (captured != null) return true; + var overlay = overlayOwner(); + if (overlay != null) { + instances.get(overlay).mouseScrolled(x, y, dx, dy); + return true; + } if (!hitArea.test(x, y)) return false; for (var e : order.reversed()) @@ -282,6 +309,11 @@ public boolean scroll(double x, double y, double dx, double dy) { public boolean keyPressed(int key, int scan, int modifiers) { if (closed) return false; + var overlay = overlayOwner(); + if (overlay != null && key == 256) { + instances.get(overlay).dismissOverlay(); + return true; + } if (key == 258) { // Tab / Shift+Tab. cancelCapture(); var candidates = order.stream().filter(this::isShown) diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/FaceConfigRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/FaceConfigRenderer.java index f11d7a4f..ae6ac2dd 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/FaceConfigRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/FaceConfigRenderer.java @@ -1,100 +1,123 @@ package zone.moddev.mc.lib.gui.client; -import java.util.*; +import java.util.IdentityHashMap; +import java.util.Map; + import com.mojang.math.Axis; + import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.renderer.*; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.core.Direction; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.block.entity.BlockEntity; + import zone.moddev.mc.lib.gui.layout.Rect; /** Shared neighbour model, tint and direction badges for face configuration drawers. */ final class FaceConfigRenderer { - private final Minecraft mc = Minecraft.getInstance(); - private final Map fadedNeighbours = new IdentityHashMap<>(); + private final Minecraft mc = Minecraft.getInstance(); + private final Map fadedNeighbours = new IdentityHashMap<>(); - Component render( - GuiGraphics g, - Rect r, - BlockEntity entity, - Direction direction, - boolean input, - boolean output) { - boolean disabled = !input && !output; - g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, disabled ? 0xbf8b8b8b : 0xff8b8b8b); - // Tint the background before drawing the neighbour so its model keeps its own colours. - int green = 0xff30e050, orange = 0xffff8a00; - int greenWash = 0x3830e050, orangeWash = 0x38ff8a00; - if (input && output) { - // Pixel-aligned diagonal from top-right to bottom-left, with no overlapping tint seam. - for (int row = 0; row < r.height() - 2; row++) { - int split = r.right() - 1 - row; - int y = r.y() + 1 + row; - g.fill(r.x() + 1, y, split, y + 1, greenWash); - g.fill(split, y, r.right() - 1, y + 1, orangeWash); - } - g.fill(r.x(), r.y(), r.right() - 1, r.y() + 1, green); - g.fill(r.x(), r.y() + 1, r.x() + 1, r.bottom() - 1, green); - g.fill(r.right() - 1, r.y(), r.right(), r.bottom(), orange); - g.fill(r.x(), r.bottom() - 1, r.right() - 1, r.bottom(), orange); - } else { - if (!disabled) - g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, input ? greenWash : orangeWash); - g.renderOutline( - r.x(), r.y(), r.width(), r.height(), disabled ? 0xbf555555 : input ? green : orange); - } - var level = entity.getLevel(); - var pos = entity.getBlockPos().relative(direction); - Component neighbour = Component.translatable("screen.mmdlib.automation.unloaded"); - if (level != null && level.hasChunkAt(pos)) { - var state = level.getBlockState(pos); - neighbour = - state.isAir() - ? Component.translatable("screen.mmdlib.automation.empty") - : state.getBlock().getName(); - if (!state.isAir()) { + /** A fixed cross distinguishes machine restrictions from player-selected Disabled. */ + static void unavailable(GuiGraphics g, Rect r) { g.pose().pushPose(); - try { - g.flush(); - g.pose().translate(r.x() + 9, r.y() + 9, 150); - g.pose().scale(7.5f, -7.5f, 7.5f); - g.pose().mulPose(Axis.XP.rotationDegrees(30)); - g.pose().mulPose(Axis.YP.rotationDegrees(225)); - g.pose().translate(-.5, -.5, -.5); - mc.getBlockRenderer() - .renderSingleBlock( - state, - g.pose(), - disabled - ? type -> - g.bufferSource() - .getBuffer( - fadedNeighbours.computeIfAbsent( - type, original -> GhostItems.fade(original, .75f))) - : g.bufferSource(), - LightTexture.FULL_BRIGHT, - OverlayTexture.NO_OVERLAY); - g.flush(); - } catch (RuntimeException unsupported) { - // A missing special renderer must not break inventory interaction. - } finally { - g.pose().popPose(); + g.pose().translate(0, 0, 220); + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, 0xff8b8b8b); + g.renderOutline(r.x(), r.y(), r.width(), r.height(), 0xff777777); + int size = Math.max(1, Math.min(r.width(), r.height()) / 2); + int x = r.x() + (r.width() - size) / 2, y = r.y() + (r.height() - size) / 2; + for (int i = 0; i < size; i++) { + g.fill(x + i, y + i, x + i + 1, y + i + 1, 0xff686868); + g.fill(x + size - 1 - i, y + i, x + size - i, y + i + 1, 0xff686868); } - } + g.pose().popPose(); + } + + Component render( + GuiGraphics g, + Rect r, + BlockEntity entity, + Direction direction, + boolean input, + boolean output) { + boolean disabled = !input && !output; + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, + disabled ? 0xbf8b8b8b : 0xff8b8b8b); + // Tint the background before drawing the neighbour so its model keeps its own colours. + int green = 0xff30e050, orange = 0xffff8a00; + int greenWash = 0x3830e050, orangeWash = 0x38ff8a00; + if (input && output) { + // Pixel-aligned diagonal from top-right to bottom-left, with no overlapping tint seam. + for (int row = 0; row < r.height() - 2; row++) { + int split = r.right() - 1 - row; + int y = r.y() + 1 + row; + g.fill(r.x() + 1, y, split, y + 1, greenWash); + g.fill(split, y, r.right() - 1, y + 1, orangeWash); + } + g.fill(r.x(), r.y(), r.right() - 1, r.y() + 1, green); + g.fill(r.x(), r.y() + 1, r.x() + 1, r.bottom() - 1, green); + g.fill(r.right() - 1, r.y(), r.right(), r.bottom(), orange); + g.fill(r.x(), r.bottom() - 1, r.right() - 1, r.bottom(), orange); + } else { + if (!disabled) + g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, + input ? greenWash : orangeWash); + g.renderOutline( + r.x(), r.y(), r.width(), r.height(), + disabled ? 0xbf555555 : input ? green : orange); + } + var level = entity.getLevel(); + var pos = entity.getBlockPos().relative(direction); + Component neighbour = Component.translatable("screen.mmdlib.automation.unloaded"); + if (level != null && level.hasChunkAt(pos)) { + var state = level.getBlockState(pos); + neighbour = state.isAir() + ? Component.translatable("screen.mmdlib.automation.empty") + : state.getBlock().getName(); + if (!state.isAir()) { + g.pose().pushPose(); + try { + g.flush(); + g.pose().translate(r.x() + 9, r.y() + 9, 150); + g.pose().scale(7.5f, -7.5f, 7.5f); + g.pose().mulPose(Axis.XP.rotationDegrees(30)); + g.pose().mulPose(Axis.YP.rotationDegrees(225)); + g.pose().translate(-.5, -.5, -.5); + mc.getBlockRenderer() + .renderSingleBlock( + state, + g.pose(), + disabled + ? type -> g.bufferSource() + .getBuffer( + fadedNeighbours.computeIfAbsent( + type, original -> GhostItems.fade(original, .75f))) + : g.bufferSource(), + LightTexture.FULL_BRIGHT, + OverlayTexture.NO_OVERLAY); + g.flush(); + } catch (RuntimeException unsupported) { + // A missing special renderer must not break inventory interaction. + } finally { + g.pose().popPose(); + } + } + } + // Keep the corner icons above the neighbour model. + g.pose().pushPose(); + g.pose().translate(0, 0, 200); + if (input) + g.blitSprite(ResourceLocation.parse("mmdlib:side_mode_input"), r.x() + 1, r.y() + 1, 7, + 7); + if (output) + g.blitSprite( + ResourceLocation.parse("mmdlib:side_mode_output"), r.right() - 8, r.bottom() - 8, 7, + 7); + g.pose().popPose(); + return neighbour; } - // Keep the corner icons above the neighbour model. - g.pose().pushPose(); - g.pose().translate(0, 0, 200); - if (input) - g.blitSprite(ResourceLocation.parse("mmdlib:side_mode_input"), r.x() + 1, r.y() + 1, 7, 7); - if (output) - g.blitSprite( - ResourceLocation.parse("mmdlib:side_mode_output"), r.right() - 8, r.bottom() - 8, 7, 7); - g.pose().popPose(); - return neighbour; - } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java b/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java index 411a22d6..a968ea2f 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/GhostItems.java @@ -14,14 +14,14 @@ import net.minecraft.world.item.ItemStack; /** Translucent samples retain their item's model, texture and lighting, including opaque blocks. */ -final class GhostItems { +public final class GhostItems { private final Map> translucent = new java.util.HashMap<>(); - void render(GuiGraphics graphics, ItemStack stack, int x, int y) { + public void render(GuiGraphics graphics, ItemStack stack, int x, int y) { render(graphics, stack, x, y, .50f); } - void render(GuiGraphics graphics, ItemStack stack, int x, int y, float alpha) { + public void render(GuiGraphics graphics, ItemStack stack, int x, int y, float alpha) { var minecraft = Minecraft.getInstance(); var renderer = minecraft.getItemRenderer(); var model = renderer.getModel(stack, minecraft.level, minecraft.player, 0); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java index 56757ef3..534bc7f0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java @@ -331,7 +331,11 @@ private void renderElements( } public List exclusions() { - return drawers.exclusions(); + var regions = new java.util.ArrayList<>(drawers.exclusions()); + var overlay = elementInput.overlayOwner(); + if (overlay != null) + elementInput.instance(overlay).overlayBounds().ifPresent(regions::add); + return List.copyOf(regions); } @Override @@ -347,10 +351,12 @@ public void render(GuiGraphics g, int mx, int my, float partial) { renderElements(ElementRenderers.Pass.FOREGROUND, g, partial, mx, my); drawers.renderTabs(g); renderElements(ElementRenderers.Pass.TOP, g, partial, mx, my); - renderTooltip(g, mx, my); + if (elementInput.overlayOwner() == null) + renderTooltip(g, mx, my); if (elementInput.hovered() != null) tooltip(g, elementInput.hovered(), mx, my); - drawers.tooltip(g, mx, my); + if (elementInput.overlayOwner() == null) + drawers.tooltip(g, mx, my); } private void tooltip(GuiGraphics g, Element e, int mx, int my) { @@ -393,6 +399,9 @@ public List tooltipLines(Element e, int mx, int my) { @Override public boolean mouseClicked(double mx, double my, int button) { updateLayout(); + updateBehaviors(); + if (elementInput.overlayOwner() != null) + return elementInput.click(mx, my, button); if (drawers.click(mx, my, button)) { elementInput.clearFocus(); return true; @@ -430,6 +439,9 @@ public void removed() { @Override public boolean mouseScrolled(double mx, double my, double dx, double dy) { + updateBehaviors(); + if (elementInput.overlayOwner() != null) + return elementInput.scroll(mx, my, dx, dy); if (drawers.scroll(mx, my, dy)) return true; updateBehaviors(); diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RecipeSelectorBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/RecipeSelectorBehavior.java new file mode 100644 index 00000000..3b8ed0c3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RecipeSelectorBehavior.java @@ -0,0 +1,205 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import net.minecraft.client.Minecraft; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.model.elements.RecipeSelectorElement; + +/** One ghost slot, with a transient strip of recipe icons when opened. */ +public final class RecipeSelectorBehavior implements ElementBehavior, IngredientDropHandler { + private static final GhostItems GHOSTS = new GhostItems(); + private final RecipeSelectorElement element; + private final ElementAccess access; + + public RecipeSelectorBehavior(RecipeSelectorElement element, ElementAccess access) { + this.element = element; + this.access = access; + } + + private CompoundTag view() { + return access.presentation().state().get(element, RecipeSelectorElement.VIEW); + } + private boolean send(String operation, String id, int page) { + return !access.pending(element) && access.submit(RecipeSelectorElement.EDIT, + new RecipeSelectorElement.Request(operation, id, page)); + } + private boolean item(ItemStack stack) { + return !stack.isEmpty() + && send("item", BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(), 0); + } + + public Optional overlayBounds() { + var state = view(); + return state.getBoolean("open") + ? Optional.of(chooserBounds(access.bounds(), access.presentation().viewport(), state)) + : Optional.empty(); + } + + public void dismissOverlay() { + send("close", "", 0); + } + + /** Shared geometry for drawing, input and client smoke checks. */ + public static Rect chooserBounds(Rect slot, Rect viewport, CompoundTag state) { + int count = Math.max(1, state.getList("rows", Tag.TAG_COMPOUND).size()); + int width = count * 18 + 4 + (state.getInt("pages") > 1 ? 24 : 0); + int x = Math.clamp(slot.x(), viewport.x(), + Math.max(viewport.x(), viewport.right() - width)); + int y = slot.bottom() + 2; + if (y + 22 > viewport.bottom()) + y = Math.max(viewport.y(), slot.y() - 24); + return new Rect(x, y, width, 22); + } + + private static int choiceIndex(Rect popup, CompoundTag state, double x, double y) { + int start = popup.x() + 2 + (state.getInt("pages") > 1 ? 12 : 0); + int count = state.getList("rows", Tag.TAG_COMPOUND).size(); + return y >= popup.y() + 2 && y < popup.bottom() - 2 && x >= start && x < start + count * 18 + ? (int) (x - start) / 18 + : -1; + } + + public Click mouseClicked(double x, double y, int button) { + if (button != 0 && button != 1) + return Click.PASS; + var state = view(); + if (button == 1) { + send("select", "", 0); + } else if (access.bounds().contains(x, y)) { + if (!access.carried().isEmpty()) + item(access.carried().copy()); + else + send(state.getBoolean("open") ? "close" : "browse", "", 0); + } else if (state.getBoolean("open")) { + var popup = overlayBounds().orElseThrow(); + int index = choiceIndex(popup, state, x, y); + if (index >= 0) { + send("select", + state.getList("rows", Tag.TAG_COMPOUND).getCompound(index).getString("id"), 0); + } else if (state.getInt("pages") > 1) { + int page = state.getInt("page") + (x < popup.x() + 14 ? -1 : 1); + if (page >= 0 && page < state.getInt("pages")) + send("page", "", page); + } + } + return Click.CONSUME; + } + + public boolean mouseScrolled(double x, double y, double dx, double dy) { + var state = view(); + if (!state.getBoolean("open") || dy == 0) + return false; + int page = state.getInt("page") + (dy > 0 ? -1 : 1); + if (page >= 0 && page < state.getInt("pages")) + send("page", "", page); + return true; + } + + public boolean shortcut(int key, int scan, int modifiers) { + return key == 256 && view().getBoolean("open") && send("close", "", 0); + } + + public List> targets(IngredientType type, T ingredient) { + if (access.pending(element) || !(ingredient instanceof ItemStack stack) || stack.isEmpty()) + return List.of(); + return List.of(new Target<>("recipe", access.bounds(), + value -> value instanceof ItemStack item && item(item))); + } + + private static ItemStack icon(CompoundTag row) { + var level = Minecraft.getInstance().level; + return level == null + ? ItemStack.EMPTY + : ItemStack.parseOptional(level.registryAccess(), row.getCompound("icon")); + } + private static Component name(CompoundTag row) { + var icon = icon(row); + return icon.isEmpty() ? Component.literal(row.getString("id")) : icon.getHoverName(); + } + private static Component text(String key, Object... values) { + return Component.translatable("screen.mmdlib.recipe_selector." + key, values); + } + private static Component reason(CompoundTag row) { + return text("reason." + row.getString("reason").toLowerCase(java.util.Locale.ROOT), + row.getString("group")); + } + + public List tooltip(int x, int y) { + var state = view(); + var row = state.getCompound("chosen"); + if (state.getBoolean("open")) { + int index = choiceIndex(overlayBounds().orElseThrow(), state, x, y); + if (index >= 0) + row = state.getList("rows", Tag.TAG_COMPOUND).getCompound(index); + } + var result = new ArrayList(); + result.add(text("produce")); + if (row.isEmpty()) + result.add(text(state.getString("selected").isEmpty() ? "none" : "missing")); + if (!row.isEmpty()) { + result.add(name(row)); + if (net.minecraft.client.gui.screens.Screen.hasShiftDown()) + result.add(Component.literal(row.getString("id"))); + result.add(text("cost", row.getLong("energy"), row.getInt("time"))); + for (var cost : row.getList("costs", Tag.TAG_STRING)) + result.add(Component.literal(cost.getAsString())); + result.add(reason(row)); + } + if (!state.getString("active").isEmpty() + && !state.getString("active").equals(state.getString("selected"))) { + var working = state.getCompound("working"); + result.add( + text("finishing", working.isEmpty() ? state.getString("active") : name(working))); + result.add(text("queued_help")); + } + result.add(text("help")); + return result; + } + + public static void render(RecipeSelectorElement element, ElementRenderers.Context c) { + var state = c.access().state().get(element, RecipeSelectorElement.VIEW); + var r = c.bounds(); + c.graphics().blitSprite(net.minecraft.resources.ResourceLocation.parse("mmdlib:slot"), + r.x(), r.y(), 18, 18); + var stack = icon(state.getCompound("chosen")); + if (!stack.isEmpty()) + GHOSTS.render(c.graphics(), stack, r.x() + 1, r.y() + 1, .50f); + } + + public static void renderChooser(RecipeSelectorElement element, ElementRenderers.Context c) { + var state = c.access().state().get(element, RecipeSelectorElement.VIEW); + if (!state.getBoolean("open")) + return; + var popup = chooserBounds(c.bounds(), c.access().viewport(), state); + var graphics = c.graphics(); + var rows = state.getList("rows", Tag.TAG_COMPOUND); + graphics.pose().pushPose(); + graphics.pose().translate(0, 0, 250); + graphics.fill(popup.x(), popup.y(), popup.right(), popup.bottom(), 0xff404b50); + int start = popup.x() + 2 + (state.getInt("pages") > 1 ? 12 : 0); + for (int i = 0; i < rows.size(); i++) { + int x = start + i * 18; + graphics.blitSprite(net.minecraft.resources.ResourceLocation.parse("mmdlib:slot"), x, + popup.y() + 2, 18, 18); + graphics.renderItem(icon(rows.getCompound(i)), x + 1, popup.y() + 3); + if (choiceIndex(popup, state, c.mouseX(), c.mouseY()) == i) + graphics.fill(x, popup.y() + 2, x + 18, popup.bottom() - 2, 0x4080e3ed); + } + if (state.getInt("pages") > 1) { + graphics.drawString(c.access().font(), "<", popup.x() + 3, popup.y() + 7, 0xff80e3ed, + false); + graphics.drawString(c.access().font(), ">", popup.right() - 9, popup.y() + 7, + 0xff80e3ed, false); + } + graphics.pose().popPose(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java index 2205683e..e425ad5a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/RedstoneDrawer.java @@ -58,6 +58,9 @@ public RedstoneDrawer( } void layout() { + if (sliderHandle >= 0 + && (!model.faceAvailable(sliderFace) || model.revision() != sliderRevision)) + sliderHandle = -1; var panel = screen.panelBounds(); tab = super.tabBounds(); if (!model.open()) @@ -106,12 +109,17 @@ public void render(GuiGraphics g, int mx, int my) { for (int face = 0; face < 6; face++) { var r = at(RedstoneLayout.face(face)); var mode = model.mode(face); - var neighbour = faces.render( - g, - r, - screen.getMenu().blockEntity, - model.control.world(face), - mode == RedstoneControl.Mode.READ, + if (!model.faceAvailable(face)) { + FaceConfigRenderer.unavailable(g, r); + if (r.contains(mx, my)) + tooltip = List.of( + Component + .translatable("screen.mmdlib.face." + FACES[face].getSerializedName()), + Component.translatable("screen.mmdlib.face.unavailable")); + continue; + } + var neighbour = faces.render(g, r, screen.getMenu().blockEntity, + model.control.world(face), mode == RedstoneControl.Mode.READ, mode == RedstoneControl.Mode.EMIT); if (mode == RedstoneControl.Mode.IGNORE) { g.pose().pushPose(); @@ -146,16 +154,18 @@ public void render(GuiGraphics g, int mx, int my) { text("face.tip")); } // Draw last and above the neighbour models, so the front face stays clearly selected. - var selectedFace = at(RedstoneLayout.face(model.selected())); - g.pose().pushPose(); - g.pose().translate(0, 0, 210); - g.renderOutline( - selectedFace.x() - 1, - selectedFace.y() - 1, - selectedFace.width() + 2, - selectedFace.height() + 2, - 0xffeeeeee); - g.pose().popPose(); + if (model.faceAvailable(model.selected())) { + var selectedFace = at(RedstoneLayout.face(model.selected())); + g.pose().pushPose(); + g.pose().translate(0, 0, 210); + g.renderOutline( + selectedFace.x() - 1, + selectedFace.y() - 1, + selectedFace.width() + 2, + selectedFace.height() + 2, + 0xffeeeeee); + g.pose().popPose(); + } renderControls(g, mx, my); renderInfo(g); g.disableScissor(); @@ -171,6 +181,8 @@ private int settingsHeight() { private Component explanation() { int face = model.selected(); + if (!model.faceAvailable(face)) + return Component.translatable("screen.mmdlib.face.unavailable"); return switch (model.mode(face)) { case READ -> rangeExplanation().copy().append("\n") @@ -240,12 +252,13 @@ private List infoLines() { infoSignal = signal; var font = textFont; var lines = new ArrayList(); - lines.addAll( - font.split( - Component.translatable( - "screen.mmdlib.face." + FACES[model.selected()].getSerializedName()) - .withStyle(ChatFormatting.BOLD), - (int) ((RedstoneLayout.WIDTH - 18) / INFO_SCALE))); + if (model.faceAvailable(face)) + lines.addAll( + font.split( + Component.translatable( + "screen.mmdlib.face." + FACES[model.selected()].getSerializedName()) + .withStyle(ChatFormatting.BOLD), + (int) ((RedstoneLayout.WIDTH - 18) / INFO_SCALE))); lines.addAll(font.split(explanation(), (int) ((RedstoneLayout.WIDTH - 18) / INFO_SCALE))); return cachedInfo = List.copyOf(lines); } @@ -280,6 +293,8 @@ private void renderInfo(GuiGraphics g) { } private void renderControls(GuiGraphics g, int mx, int my) { + if (!model.faceAvailable(model.selected())) + return; if (body.height() <= RedstoneLayout.CONTROLS_TOP) return; g.enableScissor(body.x(), body.y() + RedstoneLayout.CONTROLS_TOP, body.right(), @@ -425,7 +440,7 @@ public boolean restore(Selection selection) { model.send(RedstoneMenu.TOGGLE, 0, 0, 0); return false; } - if (model.selected() != selection.offset()) + if (model.faceAvailable(selection.offset()) && model.selected() != selection.offset()) model.send(RedstoneMenu.SELECT, selection.offset(), 0, 0); activated(); return true; @@ -488,6 +503,10 @@ public boolean click(double x, double y, int button) { int face = model.selected(); for (int i = 0; i < 6; i++) if (at(RedstoneLayout.face(i)).contains(x, y)) { + if (!model.faceAvailable(i)) { + handled = true; + return true; + } if (i != face) send(RedstoneMenu.SELECT, i, 0, 0); else @@ -499,7 +518,8 @@ public boolean click(double x, double y, int button) { handled = true; return true; } - if (y < body.y() + RedstoneLayout.CONTROLS_TOP || y >= body.bottom()) { + if (!model.faceAvailable(face) || y < body.y() + RedstoneLayout.CONTROLS_TOP + || y >= body.bottom()) { handled = true; return true; } @@ -577,12 +597,14 @@ private void updateSlider(double x) { } public boolean drag(double x, double y, int button, double dx, double dy) { + layout(); if (sliderHandle >= 0 && button == 0) updateSlider(x); return handled; } public boolean release(double x, double y, int button) { + layout(); if (sliderHandle >= 0) { if (button != 0) return true; diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java index 87189772..fc73b4ef 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigBehavior.java @@ -27,6 +27,12 @@ public int sideMode(int group, int face) { .get(group * 6 + face); } + public boolean faceAvailable(int group, int face) { + return zone.moddev.mc.lib.api.capabilities.FaceMask.available( + access.presentation().state().get(config, SideConfigElement.DISABLED_FACES).get(group), + face); + } + protected Click click(double x, double y, int button) { var bounds = access.bounds(); if (y < bounds.y() + 20) { @@ -35,7 +41,8 @@ protected Click click(double x, double y, int button) { } for (int face = 0; face < 6; face++) if (config.face(bounds, face).contains(x, y)) { - access.click(SideConfigElement.CYCLE_FACE, button, group * 6 + face); + if (faceAvailable(group, face)) + access.click(SideConfigElement.CYCLE_FACE, button, group * 6 + face); return Click.CONSUME; } return Click.PASS; @@ -48,8 +55,10 @@ public List tooltip(int x, int y) { Component.translatable( "screen.mmdlib.face." + RelativeFace.values()[face].getSerializedName()), Component.translatable( - "screen.mmdlib.mode." - + IoMode.values()[sideMode(group, face)].getSerializedName())); + faceAvailable(group, face) + ? "screen.mmdlib.mode." + + IoMode.values()[sideMode(group, face)].getSerializedName() + : "screen.mmdlib.face.unavailable")); return List.of(); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java index ee483fc6..8f187a7a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigDrawer.java @@ -479,18 +479,29 @@ private void drawFace(GuiGraphics g, int index, int mx, int my) { var r = face(index); var mode = model.mode(model.selected(), index); var relative = RelativeFace.values()[index]; + boolean available = model.faceAvailable(model.selected(), index); + if (!available) { + FaceConfigRenderer.unavailable(g, r); + if (r.contains(mx, my)) + faceTooltip = List.of( + Component.translatable("screen.mmdlib.face." + relative.getSerializedName()), + Component.translatable("screen.mmdlib.face.unavailable")); + return; + } var neighbour = faces.render( g, r, screen.getMenu().blockEntity, model.inventory.group(model.selected()).world(relative), - mode.canInsert(), - mode.canExtract()); + available && mode.canInsert(), + available && mode.canExtract()); if (r.contains(mx, my)) faceTooltip = List.of( Component.translatable("screen.mmdlib.face." + relative.getSerializedName()), neighbour, - Component.translatable("screen.mmdlib.mode." + mode.getSerializedName())); + Component.translatable(available + ? "screen.mmdlib.mode." + mode.getSerializedName() + : "screen.mmdlib.face.unavailable")); } public Rect tabBounds() { @@ -680,6 +691,8 @@ else if (y >= selector.bottom()) } for (int f = 0; f < 6; f++) if (face(f).contains(x, y)) { + if (!model.faceAvailable(model.selected(), f)) + return true; send( "face", model.selected(), diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java index 588b514c..0fc93def 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigPresentation.java @@ -4,5 +4,9 @@ public interface SideConfigPresentation { int selection(); + default boolean faceAvailable(int group, int face) { + return true; + } + int sideMode(int group, int face); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java index c84f576a..f343331a 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideConfigRenderer.java @@ -35,6 +35,11 @@ public static void draw(SideConfigElement element, ElementRenderers.Context c) { int[] colors = {0x777777, 0x679fea, 0xef994e, 0xad6fea}; for (int i = 0; i < 6; i++) { var face = element.face(r, i); + if (!access.capability(element, SideConfigPresentation.class).orElseThrow() + .faceAvailable(group, i)) { + FaceConfigRenderer.unavailable(g, face); + continue; + } tinted( g, "slot", diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java b/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java index c620f636..95dcf5ef 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/BuiltinElements.java @@ -58,6 +58,7 @@ import zone.moddev.mc.lib.gui.model.elements.ProgressElement; import zone.moddev.mc.lib.gui.model.elements.ProgressSpriteElement; import zone.moddev.mc.lib.gui.model.elements.RadioElement; +import zone.moddev.mc.lib.gui.model.elements.RecipeSelectorElement; import zone.moddev.mc.lib.gui.model.elements.SideConfigElement; import zone.moddev.mc.lib.gui.model.elements.SlotElement; import zone.moddev.mc.lib.gui.model.elements.SpriteElement; @@ -134,6 +135,11 @@ private BuiltinElements() { BuiltinElements::processorButton, "widget", "buttons"); + public static final ElementType RECIPE_SELECTOR = builtin( + "recipe-selector", + RecipeSelectorElement.class, + x -> new RecipeSelectorElement(x.common(), x.node().attributes().get("processor-key")), + "widget"); public static final ElementType BUTTON = builtin("button", ButtonElement.class, BuiltinElements::button, "widget", "buttons"); public static final ElementType CHECKBOX = builtin("checkbox", @@ -224,6 +230,7 @@ static void register(ScreenTags tags) { PROGRESS, LABEL, PROCESSOR_BUTTON, + RECIPE_SELECTOR, BUTTON, CHECKBOX, RADIO_GROUP, diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java b/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java new file mode 100644 index 00000000..f3f884f6 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java @@ -0,0 +1,273 @@ +package zone.moddev.mc.lib.gui.model.elements; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.gui.ElementAction; +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.model.Common; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.gui.sync.ElementSync; +import zone.moddev.mc.lib.gui.sync.SynchronizedElement; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.recipe.MachineProduct; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; + +/** A ghost output selector with a paged, server-authorized recipe chooser. */ +public class RecipeSelectorElement implements SynchronizedElement { + public static final int PAGE_SIZE = 3; + public static final ElementSync VIEW = ElementSync.field( + ResourceLocation.parse("mmdlib:recipe_selector"), + ByteBufCodecs.COMPOUND_TAG.cast(), + 32768, element -> new CompoundTag()) + .sample((source, previous) -> ((RecipeSelectorElement) source.element()).sample( + (ScreenHost) source.machine(), source.machine().getLevel(), previous)) + .build(); + + public record Request(String operation, String id, int page) { + public static final StreamCodec CODEC = StreamCodec.of( + (buffer, request) -> { + buffer.writeUtf(request.operation(), 16); + buffer.writeUtf(request.id(), 256); + buffer.writeVarInt(request.page()); + }, buffer -> new Request(buffer.readUtf(16), buffer.readUtf(256), buffer.readVarInt())); + } + + public static final ElementAction EDIT = new ElementAction<>( + ResourceLocation.parse("mmdlib:select_recipe"), Request.CODEC, 1024, + (context, request) -> { + var element = (RecipeSelectorElement) context.element(); + var host = (ScreenHost) context.menu().blockEntity; + var processor = element.processor(host); + var level = context.menu().blockEntity.getLevel(); + var state = context.menu().elementStates().get(element, VIEW); + if (!processor.selectionEnabled() || request.page() < 0 || request.page() > 1_000_000) + return false; + switch (request.operation()) { + case "select" -> { + var id = request.id().isEmpty() + ? null + : ResourceLocation.tryParse(request.id()); + if ((!request.id().isEmpty() && id == null) + || !processor.selectRecipe(level, id)) + return false; + state.putBoolean("open", false); + host.screenFeatures().markChanged(); + host.screenFeatures().markSync(); + } + case "browse" -> { + state.putBoolean("open", true); + state.putString("filter", ""); + state.putInt("page", request.page()); + } + case "page" -> state.putInt("page", request.page()); + case "close" -> state.putBoolean("open", false); + case "item" -> { + var item = ResourceLocation.tryParse(request.id()); + if (item == null || !BuiltInRegistries.ITEM.containsKey(item)) + return false; + var matches = processor.recipeChoices(level).stream() + .filter(holder -> produces(holder, item)).toList(); + if (matches.isEmpty()) + return false; + if (matches.size() == 1) { + if (!processor.selectRecipe(level, matches.getFirst().id())) + return false; + host.screenFeatures().markChanged(); + host.screenFeatures().markSync(); + state.putBoolean("open", false); + } else { + state.putBoolean("open", true); + state.putString("filter", item.toString()); + state.putInt("page", 0); + } + } + default -> { + return false; + } + } + state.remove("stamp"); + context.menu().elementStates().set(element, VIEW, element.sample(host, level, state)); + return true; + }); + + private final Common common; + private final String processorKey; + + public RecipeSelectorElement(Common common, String processorKey) { + this.common = Objects.requireNonNull(common); + this.processorKey = Objects.requireNonNull(processorKey); + } + + public Common common() { + return common; + } + public int[] measure(TextMeasure text) { + return new int[]{18, 18}; + } + public List> synchronizedValues() { + return List.of(VIEW); + } + public List> actions() { + return List.of(EDIT); + } + public void validate() { + if (processorKey.isBlank() || common.value().isPresent() || common.max().isPresent() + || common.clicks().any() || common.interaction().isPresent()) + throw new IllegalArgumentException( + "recipe-selector requires processor-key and owns its state/actions"); + } + public void validateBindings(Map> bindings) { + if (!bindings.getOrDefault("processors", Set.of()).contains(processorKey)) + throw new IllegalArgumentException("Unknown processor-key: " + processorKey); + } + public void validateHost(ScreenHost host) { + if (!processor(host).selectionEnabled()) + throw new IllegalArgumentException( + "recipe-selector requires a selectable processor: " + processorKey); + } + public void validateHostType(Class type) { + if (!ScreenHost.class.isAssignableFrom(type)) + throw new IllegalArgumentException("recipe-selector requires a ScreenHost"); + } + private RecipeProcessor processor(ScreenHost host) { + if (host.screenFeatures() == null) + throw new IllegalArgumentException("recipe-selector requires a recipe processor"); + return host.screenFeatures().processor(processorKey).orElseThrow( + () -> new IllegalArgumentException("Unknown processor-key: " + processorKey)); + } + + private CompoundTag sample(ScreenHost host, Level level, CompoundTag previous) { + var processor = processor(host); + String selected = processor.selectedRecipe().map(Object::toString).orElse(""); + String active = processor.activeRecipe().map(Object::toString).orElse(""); + long revision = processor.recipeRevision(level); + long now = level.getGameTime(); + if (previous.contains("stamp") && previous.getLong("revision") == revision + && previous.getString("selected").equals(selected) + && previous.getString("active").equals(active) + && previous.getString("status").equals(processor.status().name()) + && now - previous.getLong("stamp") < 20) + return previous; + var next = previous.copy(); + next.putLong("stamp", now); + next.putLong("revision", revision); + next.putString("selected", selected); + next.putString("active", active); + next.putString("status", processor.status().name()); + next.put("chosen", selected.isEmpty() + ? new CompoundTag() + : processor.selectedEntry(level, + ResourceLocation.parse(selected)).map(holder -> row(processor, level, holder)) + .orElseGet(CompoundTag::new)); + next.put("working", active.isEmpty() + ? new CompoundTag() + : processor.selectedEntry(level, + ResourceLocation.parse(active)).map(holder -> row(processor, level, holder)) + .orElseGet(CompoundTag::new)); + if (next.getBoolean("open")) { + var filter = ResourceLocation.tryParse(next.getString("filter")); + var choices = processor.recipeChoices(level).stream() + .filter(holder -> filter == null || produces(holder, filter)).toList(); + int pages = Math.max(1, (choices.size() + PAGE_SIZE - 1) / PAGE_SIZE); + int page = Math.clamp(next.getInt("page"), 0, pages - 1); + next.putInt("pages", pages); + next.putInt("page", page); + var rows = new ListTag(); + choices.stream().skip((long) page * PAGE_SIZE).limit(PAGE_SIZE) + .forEach(holder -> rows.add(row(processor, level, holder))); + next.put("rows", rows); + } else { + next.remove("rows"); + } + return next; + } + + private static boolean produces(RecipeHolder holder, + ResourceLocation item) { + return holder.value().recipe().outputs().values().stream().flatMap(List::stream) + .anyMatch(product -> product instanceof MachineProduct.ItemProduct output + && BuiltInRegistries.ITEM.getKey(output.stack().getItem()).equals(item)); + } + + private static CompoundTag row(RecipeProcessor processor, Level level, + RecipeHolder holder) { + var result = new CompoundTag(); + result.putString("id", holder.id().toString()); + var recipe = holder.value().recipe(); + var icon = recipe.outputs().values().stream().flatMap(List::stream) + .filter(MachineProduct.ItemProduct.class::isInstance) + .map(MachineProduct.ItemProduct.class::cast) + .map(MachineProduct.ItemProduct::stack).findFirst().orElse(ItemStack.EMPTY); + if (!icon.isEmpty()) + result.put("icon", icon.save(level.registryAccess())); + var match = processor.recipeAvailability(holder.value()); + result.putString("reason", match.reason().name()); + result.putString("group", match.group() == null ? "" : match.group()); + result.putInt("time", recipe.time()); + var costs = new ArrayList(); + long energy = 0; + for (var group : recipe.inputs().entrySet()) { + for (var input : group.getValue()) { + if (input instanceof MachineIngredient.EnergyPart power) { + energy += power.amount(); + } else if (input instanceof MachineIngredient.FluidPart fluid) { + // A return cannot be subtracted separately from several input claims. + // Describe complex recipes literally instead of inventing a net cost. + if (group.getValue().size() != 1 || fluid.fluids().size() != 1) { + costs.add(group.getKey() + ": " + fluid.amount() + " mB required"); + continue; + } + long returned = recipe.outputs().getOrDefault(group.getKey(), List.of()) + .stream() + .filter(product -> product instanceof MachineProduct.FluidProduct output + && output.chance() == 1 && fluid.fluids().size() == 1 + && fluid.fluids().contains(output.fluid().fluid())) + .mapToLong( + product -> ((MachineProduct.FluidProduct) product).fluid().amount()) + .sum(); + costs.add(group.getKey() + ": " + fluid.amount() + " mB required, " + + Math.max(0, fluid.amount() - returned) + " mB consumed"); + } else { + costs.add(group.getKey() + ": " + input.amount() + " items required"); + } + } + } + result.putLong("energy", energy); + var details = new ListTag(); + costs.stream().limit(32).forEach(cost -> details.add(StringTag.valueOf(cost))); + result.put("costs", details); + return result; + } + + @Override + public boolean equals(Object other) { + return other != null && getClass() == other.getClass() + && common.equals(((RecipeSelectorElement) other).common) + && processorKey.equals(((RecipeSelectorElement) other).processorKey); + } + @Override + public int hashCode() { + return Objects.hash(common, processorKey); + } + @Override + public String toString() { + return "RecipeSelectorElement[" + common + ", " + processorKey + "]"; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/elements/SideConfigElement.java b/src/main/java/zone/moddev/mc/lib/gui/model/elements/SideConfigElement.java index 2a69ac12..5d0adb71 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/elements/SideConfigElement.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/elements/SideConfigElement.java @@ -66,8 +66,40 @@ public class SideConfigElement implements SynchronizedElement { }) .build(); + public static final ElementSync> DISABLED_FACES = ElementSync.field( + ResourceLocation.parse("mmdlib:side_unavailable"), + StreamCodec.>of( + (buffer, masks) -> { + buffer.writeVarInt(masks.size()); + for (int mask : masks) + buffer.writeByte(mask); + }, + buffer -> { + int count = buffer.readVarInt(); + if (count < 0 || count > 682) + throw new IllegalArgumentException("Face policy count"); + var masks = new ArrayList(); + for (int i = 0; i < count; i++) + masks.add(zone.moddev.mc.lib.api.capabilities.FaceMask + .checked(buffer.readUnsignedByte())); + return List.copyOf(masks); + }), + 1024, + element -> Collections.nCopies(((SideConfigElement) element).groups().size(), 63)) + .validate((element, masks) -> masks.size() == ((SideConfigElement) element).groups().size() + && masks.stream().allMatch(mask -> mask >= 0 && mask <= 63)) + .sample((source, previous) -> { + var config = (SideConfigElement) source.element(); + var sides = source.machine() instanceof ScreenHost host + ? host.screenSides() + : java.util.Map.of(); + return config.groups().stream() + .map(group -> sides.containsKey(group) ? sides.get(group).disabledFaceMask() : 63) + .toList(); + }).build(); + public List> synchronizedValues() { - return List.of(MODES); + return List.of(MODES, DISABLED_FACES); } /** Copies the groups. */ @@ -116,7 +148,7 @@ public void validateScreen(Screen screen) { .menu().blockEntity instanceof zone.moddev.mc.lib.gui.runtime.ScreenHost host)) return false; var sides = host.screenSides().get(element.groups().get(group)); - if (sides == null) + if (sides == null || !sides.faceAvailable(RelativeFace.values()[face])) return false; sides.cycle(zone.moddev.mc.lib.api.capabilities.RelativeFace.values()[face]); return true; diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java index 117e3250..9ce7ec79 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationMenu.java @@ -85,7 +85,7 @@ public void addSlots( private long editorGeneration; private net.minecraft.resources.ResourceLocation editorType; private long[] generations, revisions; - private int[] modes; + private int[] modes, disabledFaces; private long lastInventoryRevision = -1; private int lastSelected = -1, lastEditor = -2; private boolean lastOpen; @@ -119,6 +119,8 @@ public AutomationMenu( this.client = client; addons = !(menu.blockEntity instanceof ScreenHost host) || host.screenAutomationAddons(); modes = new int[inventory.groups().size() * 6]; + disabledFaces = new int[inventory.groups().size()]; + java.util.Arrays.fill(disabledFaces, 63); generations = new long[inventory.groups().size() * COLUMNS]; revisions = new long[generations.length]; if (client) { @@ -258,6 +260,12 @@ public long revision(int g, int c) { return client ? revisions[g * COLUMNS + c] : inventory.revision(g, c); } + public boolean faceAvailable(int group, int face) { + return client + ? zone.moddev.mc.lib.api.capabilities.FaceMask.available(disabledFaces[group], face) + : inventory.group(group).sides().faceAvailable(RelativeFace.values()[face]); + } + public IoMode mode(int g, int face) { return client ? IoMode.values()[modes[g * 6 + face]] @@ -514,11 +522,12 @@ && supportsFilter(g, c) private AutomationNetwork.State snapshot(boolean accepted) { int n = inventory.groups().size(); - int[] modes = new int[n * 6]; + int[] modes = new int[n * 6], masks = new int[n]; long[] gens = new long[n * COLUMNS], revs = new long[n * COLUMNS]; String[] ids = new String[n]; for (int g = 0; g < n; g++) { ids[g] = inventory.group(g).definitionKey(); + masks[g] = inventory.group(g).sides().disabledFaceMask(); for (int f = 0; f < 6; f++) modes[g * 6 + f] = inventory.group(g).sides().get(RelativeFace.values()[f]) .ordinal(); @@ -538,7 +547,8 @@ private AutomationNetwork.State snapshot(boolean accepted) { ids, modes, gens, - revs); + revs, + masks); } private void transmit(boolean accepted) { @@ -575,6 +585,7 @@ public void receive(AutomationNetwork.State state) { open = state.open(); editor = state.editor(); modes = state.modes(); + disabledFaces = state.disabledFaces(); generations = state.generations(); revisions = state.revisions(); requests.acknowledgeExact(state.request()); diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java index 68c9b78b..6159ffc0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/AutomationNetwork.java @@ -13,7 +13,7 @@ /** Dedicated menu-bound protocol for the opt-in drawer and item-provided editor. */ public final class AutomationNetwork { - public static final String VERSION = "16"; + public static final String VERSION = "17"; private AutomationNetwork() { } @@ -77,9 +77,23 @@ public record State( String[] groups, int[] modes, long[] generations, - long[] revisions) + long[] revisions, + int[] disabledFaces) implements CustomPacketPayload { + public State { + if (disabledFaces.length != groups.length) + throw new IllegalArgumentException("Face policy count"); + disabledFaces = disabledFaces.clone(); + for (int mask : disabledFaces) + zone.moddev.mc.lib.api.capabilities.FaceMask.checked(mask); + } + public State(int menu, long session, int request, boolean accepted, int selected, + boolean open, int editor, String[] groups, int[] modes, long[] generations, + long[] revisions) { + this(menu, session, request, accepted, selected, open, editor, groups, modes, + generations, revisions, new int[groups.length]); + } public static final Type TYPE = new Type<>( ResourceLocation.parse("mmdlib:automation_state")); public static final StreamCodec CODEC = StreamCodec.of( @@ -95,6 +109,7 @@ public record State( for (String id : p.groups) b.writeUtf(id, 128); b.writeVarIntArray(p.modes); + b.writeVarIntArray(p.disabledFaces); for (long v : p.generations) b.writeLong(v); for (long v : p.revisions) @@ -119,6 +134,7 @@ public record State( for (int mode : modes) if (mode < 0 || mode > 3) throw new IllegalArgumentException("Face mode"); + int[] disabledFaces = b.readVarIntArray(count); long[] generations = new long[count * COLUMNS], revisions = new long[count * COLUMNS]; for (int i = 0; i < generations.length; i++) @@ -138,7 +154,8 @@ public record State( groups, modes, generations, - revisions); + revisions, + disabledFaces); }); public Type type() { diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java index 84e3fa5d..ddbb8f56 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java @@ -54,6 +54,8 @@ public ItemStack slotHint(SlotElement slot) { private final Map elementIndexes = new IdentityHashMap<>(); private final Map values = new HashMap<>(); private final ScreenHost host; + private final net.minecraft.core.BlockPos interactionAnchor; + private final String interactionIdentity; private final Player owner; private final MenuComponents components; private final MenuSlots nativeSlots = new MenuSlots(); @@ -76,7 +78,12 @@ public boolean interacting() { private final ElementActionSession elementActions; public MachineMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { - this(id, inventory, readEntity(inventory, data), data.readLong()); + this(id, inventory, readEntity(inventory, data), data.readLong(), data.readBlockPos()); + } + + public MachineMenu(int id, Inventory inventory, BlockEntity entity, long session, + net.minecraft.core.BlockPos anchor) { + this(id, inventory, entity, inventory.player.level().isClientSide, session, anchor); } public MachineMenu(int id, Inventory inventory, BlockEntity entity, long session) { @@ -204,6 +211,12 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c private MachineMenu( int id, Inventory inventory, BlockEntity entity, boolean client, long session) { + this(id, inventory, entity, client, session, entity.getBlockPos()); + } + + private MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean client, + long session, + net.minecraft.core.BlockPos anchor) { super(ScreenRuntime.MENUS.get(), id); owner = inventory.player; blockEntity = entity; @@ -217,6 +230,8 @@ private MachineMenu( if (elements.size() > 4096) throw new IllegalArgumentException("A screen may have at most 4096 elements"); host = entity instanceof ScreenHost h ? h : null; + interactionAnchor = anchor.immutable(); + interactionIdentity = host == null ? "" : host.screenInteractionIdentity(anchor); elementStates = new ElementStates(elements, owner.registryAccess(), this::stateLayoutChanged); elementActions = new ElementActionSession(this, session, owner.registryAccess(), values, @@ -360,8 +375,15 @@ public void navigate(String context, String view) { public boolean stillValid(Player player) { return !blockEntity.isRemoved() && player.level() == blockEntity.getLevel() + && player.level().hasChunkAt(blockEntity.getBlockPos()) && player.level().getBlockEntity(blockEntity.getBlockPos()) == blockEntity - && player.canInteractWithBlock(blockEntity.getBlockPos(), 4.0); + && player.level().hasChunkAt(interactionAnchor) + && (host == null + ? interactionAnchor.equals(blockEntity.getBlockPos()) + : host.screenInteractionAnchor(interactionAnchor) + && interactionIdentity + .equals(host.screenInteractionIdentity(interactionAnchor))) + && player.canInteractWithBlock(interactionAnchor, 4.0); } @Override diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneMenu.java index f498d9df..4d91ab16 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneMenu.java @@ -66,7 +66,24 @@ public boolean conflicts(boolean manuallyPaused) { return watching() && manuallyPaused != paused(); } + public boolean faceAvailable(int face) { + return client + ? state != null && zone.moddev.mc.lib.api.capabilities.FaceMask + .available(state.disabledFaces(), face) + : control.faceAvailable(face); + } + private void normalizeSelection() { + if (faceAvailable(selected)) + return; + selected = 0; // Harmless placeholder when every face is unavailable; no controls are shown. + for (int face = 0; face < 6; face++) + if (faceAvailable(face)) { + selected = face; + break; + } + } public int selected() { + normalizeSelection(); return selected; } @@ -115,6 +132,8 @@ public void closeServer() { } public boolean send(int kind, int face, int column, int value) { + if (kind != TOGGLE && kind != SYNC && !faceAvailable(face)) + return false; int request = requests.begin(); if (request == 0) return false; @@ -139,12 +158,13 @@ public void action(Player player, RedstoneNetwork.Action p) { || p.session() != menu.sessionId() || !received.receive(p.request())) return; + normalizeSelection(); if (received.allow(player.level().getGameTime())) { if (p.kind() == TOGGLE) { open = !open; if (open) menu.components().activate(this); - } else if (open) { + } else if (open && faceAvailable(p.face())) { if (p.kind() == SELECT) selected = p.face(); else if (p.face() == selected && p.revision() == revision()) @@ -185,6 +205,7 @@ public void receive(RedstoneNetwork.State p) { state = p; open = p.open(); selected = p.selected(); + normalizeSelection(); requests.acknowledgeThrough(p.acknowledged()); } @@ -193,6 +214,7 @@ public void broadcast() { || !(owner instanceof net.minecraft.server.level.ServerPlayer player) || !player.connection.hasChannel(RedstoneNetwork.State.TYPE)) return; + normalizeSelection(); int[] faces = snapshotFaces; for (int i = 0; i < 6; i++) { faces[i * 8] = mode(i).ordinal(); @@ -210,6 +232,7 @@ public void broadcast() { || sent.open() != open || sent.selected() != selected || sent.paused() != control.paused() + || sent.disabledFaces() != control.disabledFaceMask() || !Arrays.equals(sent.faces(), faces)) { // State owns a clone, so the scratch buffer cannot mutate an in-flight packet. var next = new RedstoneNetwork.State( @@ -220,7 +243,8 @@ public void broadcast() { open, selected, control.paused(), - faces); + faces, + control.disabledFaceMask()); PacketDistributor.sendToPlayer(player, next); sent = next; } diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneNetwork.java index bf6b50ee..f821e0fb 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneNetwork.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/RedstoneNetwork.java @@ -13,7 +13,7 @@ * revision. */ public final class RedstoneNetwork { - public static final String VERSION = "16"; + public static final String VERSION = "17"; private RedstoneNetwork() { } @@ -73,10 +73,12 @@ public record State( boolean open, int selected, boolean paused, - int[] faces) + int[] faces, + int disabledFaces) implements CustomPacketPayload { public State { + zone.moddev.mc.lib.api.capabilities.FaceMask.checked(disabledFaces); faces = faces.clone(); if (acknowledged < 0 || revision < 0 || selected < 0 || selected > 5 || faces.length != 48) @@ -89,6 +91,10 @@ public record State( throw new IllegalArgumentException("Invalid face state"); } + public State(int menu, long session, int acknowledged, long revision, boolean open, + int selected, boolean paused, int[] faces) { + this(menu, session, acknowledged, revision, open, selected, paused, faces, 0); + } public static final Type TYPE = new Type<>( ResourceLocation.parse("mmdlib:redstone_state")); public static final StreamCodec CODEC = StreamCodec.of( @@ -101,6 +107,7 @@ public record State( b.writeVarInt(p.selected); b.writeBoolean(p.paused); b.writeVarIntArray(p.faces); + b.writeByte(p.disabledFaces); }, b -> new State( b.readVarInt(), @@ -110,7 +117,8 @@ public record State( b.readBoolean(), b.readVarInt(), b.readBoolean(), - b.readVarIntArray(48))); + b.readVarIntArray(48), + b.readUnsignedByte())); public Type type() { return TYPE; diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java index 5b442cdc..41593ff7 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java @@ -11,6 +11,17 @@ /** Implement on a block entity to expose its inventory and side groups to a data-defined menu. */ public interface ScreenHost { + /** Validated physical interaction anchor; single-block hosts keep their existing position. */ + default boolean screenInteractionAnchor(net.minecraft.core.BlockPos anchor) { + return this instanceof net.minecraft.world.level.block.entity.BlockEntity entity + && entity.getBlockPos().equals(anchor); + } + + /** A changing formation identity invalidates previously opened member-anchored sessions. */ + default String screenInteractionIdentity(net.minecraft.core.BlockPos anchor) { + return ""; + } + /** * The inventory behind the screen's {@code } elements, numbered as in the XML; * returning an {@code ItemStorage} keeps its output restrictions and filters for players. It diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java index 18382de1..c1bf3403 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java @@ -18,7 +18,7 @@ /** Versioned menu protocol. Every mutation is checked against the sender's current menu. */ public final class ScreenNetwork { - public static final String VERSION = "20"; + public static final String VERSION = "21"; private ScreenNetwork() { } diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java index 8ab7cc69..8a82071f 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java @@ -115,8 +115,23 @@ public static String fingerprint(zone.moddev.mc.lib.gui.model.Screen screen, } public static boolean open(Player player, BlockEntity entity) { + return entity != null && open(player, entity, entity.getBlockPos()); + } + + /** Opens at a host-authorized physical member without weakening controller identity checks. */ + public static boolean open(Player player, BlockEntity entity, + net.minecraft.core.BlockPos anchor) { if (entity == null) return false; + if (!player.level().isClientSide + && (entity.isRemoved() || entity.getLevel() != player.level() + || !player.level().hasChunkAt(entity.getBlockPos()) + || player.level().getBlockEntity(entity.getBlockPos()) != entity + || !player.level().hasChunkAt(anchor) || !player.canInteractWithBlock(anchor, 4.0) + || !(entity instanceof ScreenHost host + ? host.screenInteractionAnchor(anchor) + : anchor.equals(entity.getBlockPos())))) + return false; var bound = Screens.get(BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(entity.getType())); if (bound.isEmpty() || !bound.get().blockEntityClass().isInstance(entity)) return false; @@ -128,11 +143,13 @@ public static boolean open(Player player, BlockEntity entity) { .orElseGet(() -> Component.translatable(text.key())); server.openMenu( new SimpleMenuProvider( - (id, inventory, p) -> new MachineMenu(id, inventory, entity, session), title), + (id, inventory, p) -> new MachineMenu(id, inventory, entity, session, anchor), + title), buf -> { buf.writeBlockPos(entity.getBlockPos()); buf.writeUtf(fingerprint(bound.get().screen(), entity)); buf.writeLong(session); + buf.writeBlockPos(anchor); }); } return true; diff --git a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineColors.java b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineColors.java new file mode 100644 index 00000000..64838b9a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineColors.java @@ -0,0 +1,68 @@ +package zone.moddev.mc.lib.machine.definition; + +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +import zone.moddev.mc.lib.api.capabilities.IoMode; + +/** The default machine resource palette. Names are presentation choices, not IO permissions. */ +public final class MachineColors { + private static final Map PALETTE = Collections + .unmodifiableMap(new TreeMap<>(Map.ofEntries( + Map.entry("input.primary", 0x568bd8), + Map.entry("input.secondary", 0xa184ce), + Map.entry("input.tertiary", 0xcf84aa), + Map.entry("output.primary", 0x4dc6d8), + Map.entry("output.secondary", 0x83b967), + Map.entry("output.tertiary", 0xd9bb62), + Map.entry("fluid.primary", 0x508ee0), + Map.entry("fluid.secondary", 0xee963c), + Map.entry("fluid.tertiary", 0x9c83cf), + Map.entry("energy", 0xd85b5b), + Map.entry("storage", 0x8c9eac), + Map.entry("manual", 0xaaa0b7)))); + private static final String[] VARIANTS = {"primary", "secondary", "tertiary"}; + + private MachineColors() { + } + + /** Immutable, alphabetically ordered names and their RGB values. */ + public static Map palette() { + return PALETTE; + } + + /** Accepts a case-sensitive palette name or exactly six hexadecimal RGB digits after '#'. */ + public static int parse(String value) { + if (value != null && value.matches("#[0-9a-fA-F]{6}")) + return Integer.parseInt(value.substring(1), 16); + var named = value == null ? null : PALETTE.get(value); + if (named != null) + return named; + throw new IllegalArgumentException("Invalid color='" + value + + "': expected #RRGGBB or a palette name: " + String.join(", ", PALETTE.keySet())); + } + + /** + * Default for the zero-based group ordinal within an item IO mode; variants repeat after three. + */ + public static int item(IoMode mode, int ordinal) { + return parse(switch (mode) { + case INPUT -> "input." + variant(ordinal); + case OUTPUT -> "output." + variant(ordinal); + case BOTH -> "storage"; + case DISABLED -> "manual"; + }); + } + + /** Default for the zero-based fluid group ordinal, independent of item groups. */ + public static int fluid(int ordinal) { + return parse("fluid." + variant(ordinal)); + } + + private static String variant(int ordinal) { + if (ordinal < 0) + throw new IllegalArgumentException("Negative palette ordinal"); + return VARIANTS[ordinal % VARIANTS.length]; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java index 00d63908..e35c4aa2 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java @@ -1,13 +1,19 @@ package zone.moddev.mc.lib.machine.definition; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import net.minecraft.resources.ResourceLocation; import zone.moddev.mc.lib.api.capabilities.IoMode; import zone.moddev.mc.lib.api.capabilities.Role; import zone.moddev.mc.lib.gui.load.ScreenReader.Node; +import zone.moddev.mc.lib.machine.presentation.BuiltinVisualVariables; +import zone.moddev.mc.lib.machine.presentation.ConditionalFaceTexture; +import zone.moddev.mc.lib.machine.presentation.ConditionalModelPart; /** Immutable, resolved startup configuration for a powered item-processing machine. */ public record MachineDefinition( @@ -18,6 +24,9 @@ public record MachineDefinition( ResourceLocation mineable, boolean requiresTool, Map textures, + ResourceLocation baseModel, + List modelParts, + List faceTextures, Power power, Fluids fluids, String itemStorageId, @@ -27,13 +36,90 @@ public record MachineDefinition( String processor, String processorName, boolean redstone, - Node screen) { + Node screen, + RecipeSelection recipeSelection, + List ghosts) { + public record Ghost(String id, int count, ResourceLocation defaultItem) { + } + + /** + * Inputs that are ghost samples on every XML machine using this recipe type. Shared recipe + * displays must not label a real input as a sample when machines disagree about its storage. + */ + public static Set ghostInputsFor( + Collection definitions, ResourceLocation recipeType) { + Set result = null; + for (var machine : definitions) { + if (!machine.recipeType().id().equals(recipeType)) + continue; + var ghosts = new HashSet(); + machine.ghosts().forEach(ghost -> ghosts.add(ghost.id())); + if (result == null) + result = ghosts; + else + result.retainAll(ghosts); + } + return result == null ? Set.of() : Set.copyOf(result); + } + + public MachineDefinition(ResourceLocation id, String checksum, String name, float hardness, + ResourceLocation mineable, boolean requiresTool, Map textures, + ResourceLocation baseModel, List modelParts, + List faceTextures, Power power, Fluids fluids, String itemStorageId, + List slots, List groups, RecipeType recipeType, String processor, + String processorName, boolean redstone, Node screen, RecipeSelection recipeSelection) { + this(id, checksum, name, hardness, mineable, requiresTool, textures, baseModel, modelParts, + faceTextures, power, fluids, itemStorageId, slots, groups, recipeType, processor, + processorName, redstone, screen, recipeSelection, List.of()); + } + public record RecipeSelection(ResourceLocation defaultRecipe) { + } + + public MachineDefinition(ResourceLocation id, String checksum, String name, float hardness, + ResourceLocation mineable, boolean requiresTool, Map textures, + ResourceLocation baseModel, List modelParts, + List faceTextures, Power power, Fluids fluids, String itemStorageId, + List slots, List groups, RecipeType recipeType, String processor, + String processorName, boolean redstone, Node screen) { + this(id, checksum, name, hardness, mineable, requiresTool, textures, baseModel, modelParts, + faceTextures, power, fluids, itemStorageId, slots, groups, recipeType, processor, + processorName, redstone, screen, null); + } + /** Cube-front configuration retained for existing Java callers. */ + public MachineDefinition(ResourceLocation id, String checksum, String name, float hardness, + ResourceLocation mineable, boolean requiresTool, Map textures, + List modelParts, List faceTextures, + Power power, Fluids fluids, String itemStorageId, List slots, List groups, + RecipeType recipeType, String processor, String processorName, boolean redstone, + Node screen) { + this(id, checksum, name, hardness, mineable, requiresTool, textures, null, modelParts, + faceTextures, power, fluids, itemStorageId, slots, groups, recipeType, processor, + processorName, redstone, screen); + } + public MachineDefinition { + ghosts = List.copyOf(ghosts); textures = Map.copyOf(textures); + modelParts = List.copyOf(modelParts); + faceTextures = List.copyOf(faceTextures); slots = List.copyOf(slots); groups = List.copyOf(groups); } + public Map visualDefaults() { + return BuiltinVisualVariables + .forMachine(this, null, null, null, null).defaults(); + } + + public boolean hasConditionalAppearance() { + return !modelParts.isEmpty() || !faceTextures.isEmpty(); + } + + public List visualConditions() { + return java.util.stream.Stream.concat(modelParts.stream().map(ConditionalModelPart::when), + faceTextures.stream().map(ConditionalFaceTexture::when)).toList(); + } + public record Power(String id, long capacity, long input, long output, int color) { } @@ -67,7 +153,11 @@ public record Fluids(String id, List tanks, List groups) { } } - public record Group(String id, List members, IoMode mode, int color) { + public record Group(String id, List members, IoMode mode, int color, + boolean automation) { + public Group(String id, List members, IoMode mode, int color) { + this(id, members, mode, color, true); + } public Group { members = List.copyOf(members); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java index 76391c09..be4afd7f 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java @@ -21,6 +21,7 @@ import net.minecraft.resources.ResourceLocation; import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.api.capabilities.RelativeFace; import zone.moddev.mc.lib.api.capabilities.Role; import zone.moddev.mc.lib.common.loading.DefinitionLoader.SourceRoot; import zone.moddev.mc.lib.gui.load.ScreenException; @@ -34,6 +35,9 @@ import zone.moddev.mc.lib.machine.definition.MachineDefinition.Slot; import zone.moddev.mc.lib.machine.definition.MachineDefinition.Tank; import zone.moddev.mc.lib.machine.inventory.MachineStorageKeys; +import zone.moddev.mc.lib.machine.presentation.ConditionalFaceTexture; +import zone.moddev.mc.lib.machine.presentation.ConditionalModelPart; +import zone.moddev.mc.lib.machine.presentation.VisualCondition; /** * Discovers machine XML, composes inheritance, then validates concrete plans before registration. @@ -120,6 +124,11 @@ private static Node merge(Node base, Node child) { if (child.name().equals("generator") || child.name().equals("fill")) return child; var attrs = new LinkedHashMap<>(base.attributes()); + // A new preset replaces the previous preset's base configuration, but retains parts. + if (child.name().equals("appearance") && child.attributes().containsKey("preset") + && !child.attributes().get("preset").equals(base.attributes().get("preset"))) + for (var attribute : List.of("front", "side", "top", "bottom", "model")) + attrs.remove(attribute); attrs.putAll(child.attributes()); var children = new ArrayList<>(base.children()); var indexes = new HashMap(); @@ -154,19 +163,23 @@ private static Node merge(Node base, Node child) { private static final Map> ATTRIBUTES = Map.ofEntries( Map.entry("machine", Set.of("version", "abstract", "extends", "name")), Map.entry("block", Set.of("hardness", "facing", "drop", "mineable", "requires-tool")), - Map.entry("appearance", Set.of("preset", "front", "side", "top", "bottom")), + Map.entry("appearance", Set.of("preset", "front", "side", "top", "bottom", "model")), + Map.entry("part", Set.of("id", "model", "when")), + Map.entry("face-texture", Set.of("id", "face", "texture", "when", "emissive-mask")), Map.entry("storage", Set.of()), Map.entry("items", Set.of("id")), Map.entry("fluids", Set.of("id")), - Map.entry("tank", Set.of("id", "capacity", "role", "fluid")), - Map.entry("tanks", Set.of("id", "count", "capacity", "role", "fluid")), - Map.entry("energy", Set.of("id", "capacity", "input-rate", "output-rate")), - Map.entry("slots", Set.of("id", "count", "role", "limit")), - Map.entry("slot", Set.of("id", "role", "limit")), + Map.entry("tank", Set.of("id", "capacity", "role", "fluid", "color", "automation")), + Map.entry("tanks", + Set.of("id", "count", "capacity", "role", "fluid", "color", "automation")), + Map.entry("energy", Set.of("id", "capacity", "input-rate", "output-rate", "color")), + Map.entry("slots", Set.of("id", "count", "role", "limit", "color", "type", "default-item")), + Map.entry("slot", Set.of("id", "count", "role", "limit", "type", "default-item")), Map.entry("groups", Set.of()), Map.entry("group", Set.of("id", "storage", "members", "mode", "color")), Map.entry("recipe-type", Set.of("id", "declare", "name")), - Map.entry("processor", Set.of("id", "name", "recipe-type", "filter-inputs")), + Map.entry("processor", + Set.of("id", "name", "recipe-type", "filter-inputs", "selectable", "default-recipe")), Map.entry("redstone", Set.of("enabled", "pause")), Map.entry("recovery", Set.of("policy"))); private static final Map> CHILDREN = Map.of( @@ -189,6 +202,8 @@ private static Node merge(Node base, Node child) { Set.of("slot", "slots"), "groups", Set.of("group"), + "appearance", + Set.of("part", "face-texture"), "recipe-type", Set.of("generator")); @@ -210,10 +225,13 @@ private static void validateTree(Node node) { var resources = switch (node.name()) { case "machine" -> List.of("extends"); case "block" -> List.of("mineable"); - case "appearance" -> List.of("front", "side", "top", "bottom"); + case "appearance" -> List.of("front", "side", "top", "bottom", "model"); + case "part" -> List.of("model"); + case "face-texture" -> List.of("texture", "emissive-mask"); case "recipe-type" -> List.of("id"); - case "processor" -> List.of("recipe-type"); + case "processor" -> List.of("recipe-type", "default-recipe"); case "tank", "tanks" -> List.of("fluid"); + case "slot", "slots" -> List.of("default-item"); default -> List.of(); }; for (var attribute : resources) @@ -221,6 +239,8 @@ private static void validateTree(Node node) { resource(node, attribute); if (node.name().equals("block") && node.attributes().containsKey("hardness")) hardness(node); + if (Set.of("slots", "tank", "tanks", "energy", "group").contains(node.name())) + color(node, 0); var seen = new HashSet(); for (var child : node.children()) { if (!CHILDREN.getOrDefault(node.name(), Set.of()).contains(child.name())) @@ -240,15 +260,29 @@ private static MachineDefinition plan( requireChoice(block, "facing", "horizontal"); requireChoice(block, "drop", "self"); var appearance = one(root, "appearance"); - requireChoice(appearance, "preset", "cube-front"); + requireChoice(appearance, "preset", "cube-front", "model"); var textures = new HashMap(); - for (var face : List.of("front", "side", "top")) - textures.put(face, resource(appearance, face).toString()); - textures.put( - "bottom", - appearance.attributes().containsKey("bottom") - ? resource(appearance, "bottom").toString() - : textures.get("side")); + ResourceLocation baseModel = null; + if (required(appearance, "preset").equals("model")) { + baseModel = resource(appearance, "model"); + if (baseModel.equals(id.withPrefix("block/")) + || baseModel.equals(id.withPrefix("block/").withSuffix("_base"))) + throw error(appearance, + "The base model cannot reference this machine's generated models"); + for (var face : List.of("front", "side", "top", "bottom")) + if (appearance.attributes().containsKey(face)) + throw error(appearance, "The model preset does not support attribute " + face); + } else { + if (appearance.attributes().containsKey("model")) + throw error(appearance, "The cube-front preset does not support attribute model"); + for (var face : List.of("front", "side", "top")) + textures.put(face, resource(appearance, face).toString()); + textures.put( + "bottom", + appearance.attributes().containsKey("bottom") + ? resource(appearance, "bottom").toString() + : textures.get("side")); + } var storage = one(root, "storage"); var power = optional(storage, "energy"); var items = one(storage, "items"); @@ -259,7 +293,9 @@ private static MachineDefinition plan( // BasePoweredMachine owns this stable save key. if (powerId != null && !powerId.equals("energy")) throw error(power, "Powered machines require energy id='energy'"); - int energyColor = 0xd85b5b; + int energyColor = power == null + ? MachineColors.parse("energy") + : color(power, MachineColors.parse("energy")); var fluidNode = optional(storage, "fluids"); var tanks = new ArrayList(); var fluidGroups = new LinkedHashMap(); @@ -273,6 +309,9 @@ private static MachineDefinition plan( for (var node : fluidNode.children()) { String tankId = required(node, "id"); Role role = role(node); + boolean automation = bool(node, "automation", true); + if (!automation && role != Role.MANUAL) + throw error(node, "Tanks without automation must use role=manual"); int count = node.name().equals("tanks") ? (int) number(node, "count", 1, 16) : 1; int capacity = (int) number(node, "capacity", 1, Integer.MAX_VALUE); var filter = node.attributes().containsKey("fluid") @@ -287,13 +326,15 @@ private static MachineDefinition plan( tanks.add(new Tank(member, role, capacity, filter)); } if (fluidGroups.putIfAbsent(tankId, - new Group(tankId, members, mode(role), 0xf0a13b)) != null) + new Group(tankId, members, mode(role), + color(node, MachineColors.fluid(fluidGroups.size())), automation)) != null) throw error(node, "Duplicate fluid group " + tankId); } if (tanks.isEmpty() || tanks.size() > 16) throw error(fluidNode, "Expected 1..16 tanks"); } var slots = new ArrayList(); + var ghosts = new ArrayList(); var groups = new LinkedHashMap(); var ids = new HashMap(); for (var node : items.children()) { @@ -304,7 +345,25 @@ private static MachineDefinition plan( } catch (IllegalArgumentException ex) { throw error(node, "Invalid slot role"); } - int count = node.name().equals("slots") ? (int) number(node, "count", 1, 54) : 1; + int count = node.name().equals("slots") + ? (int) number(node, "count", 1, 54) + : node.attributes().containsKey("count") ? (int) number(node, "count", 1, 1) : 1; + String slotType = node.attributes().getOrDefault("type", "item"); + if (!Set.of("item", "ghost").contains(slotType)) + throw error(node, "Slot type must be item or ghost"); + if (slotType.equals("ghost")) { + if (role != Role.INPUT || node.attributes().containsKey("limit")) + throw error(node, "Ghost slots require role=input and always hold one sample"); + if (ghosts.stream().anyMatch(ghost -> ghost.id().equals(slotId))) + throw error(node, "Duplicate ghost inventory " + slotId); + ghosts.add(new MachineDefinition.Ghost(slotId, count, + node.attributes().containsKey("default-item") + ? resource(node, "default-item") + : null)); + continue; + } + if (node.attributes().containsKey("default-item")) + throw error(node, "default-item is only supported for ghost slots"); int limit = node.attributes().containsKey("limit") ? (int) number(node, "limit", 1, 64) : 64; @@ -325,11 +384,13 @@ private static MachineDefinition plan( }; if (groups.putIfAbsent( slotId, new Group(slotId, members, mode, - role == Role.INPUT ? 0x568bd8 : 0xe48f45)) != null) + color(node, defaultItemColor(groups, mode)))) != null) throw error(node, "Duplicate group " + slotId); } } - if (slots.isEmpty() || slots.size() > 54) + int totalSlots = slots.size() + + ghosts.stream().mapToInt(MachineDefinition.Ghost::count).sum(); + if (totalSlots == 0 || totalSlots > 54) throw error(items, "Expected 1..54 item slots"); var groupsNode = optional(root, "groups"); if (groupsNode != null) @@ -366,7 +427,8 @@ && required(node, "storage").equals(required(fluidNode, "id"))) { } fluidGroups.put( groupId, - new Group(groupId, prior.members(), mode, color(node, prior.color()))); + new Group(groupId, prior.members(), mode, color(node, prior.color()), + prior.automation())); continue; } if (!required(node, "storage").equals(required(items, "id"))) @@ -395,7 +457,8 @@ else if (prior != null) groupId, new Group( groupId, members, mode, - color(node, prior == null ? 0x568bd8 : prior.color()))); + color(node, + prior == null ? defaultItemColor(groups, mode) : prior.color()))); } var covered = new HashSet(); for (var group : groups.values()) @@ -411,6 +474,13 @@ else if (prior != null) throw error(items, "Every slot must belong to a group"); if (groups.containsKey(powerId)) throw error(items, "Item group conflicts with energy"); + for (var ghost : ghosts) + if (groups.containsKey(ghost.id()) || ids.containsKey(ghost.id()) + || fluidGroups.containsKey(ghost.id()) + || ghost.id().equals(powerId) || ghost.id().equals(required(items, "id")) + || MachineStorageKeys.RESERVED.contains(ghost.id())) + throw error(items, + "Ghost inventory conflicts with another resource: " + ghost.id()); for (var group : fluidGroups.values()) { if (groups.containsKey(group.id()) || group.id().equals(powerId)) throw error(fluidNode, "Duplicate resource group " + group.id()); @@ -466,7 +536,40 @@ else if (prior != null) attrs.put("title-fallback", required(root, "name")); attrs.put("block-entity", "zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity"); float hardness = hardness(block); - return new MachineDefinition( + var modelParts = new ArrayList(); + var faceTextures = new ArrayList(); + if (appearance.children().size() > 64) + throw error(appearance, + "At most 64 conditional parts and face-texture rules are supported"); + for (var part : appearance.children()) { + if (part.name().equals("face-texture")) { + if (baseModel != null) + throw error(part, + "The model preset only supports part children, not face-texture"); + RelativeFace face; + try { + face = RelativeFace.valueOf(required(part, "face").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw error(part, "Expected face front, back, left, right, top or bottom"); + } + faceTextures.add(new ConditionalFaceTexture( + required(part, "id"), face, resource(part, "texture"), required(part, "when"), + part.attributes().containsKey("emissive-mask") + ? resource(part, "emissive-mask") + : null)); + continue; + } + if (required(part, "id").equals("base")) + throw error(part, "The model part ID 'base' is reserved"); + if (resource(part, "model").equals(id.withPrefix("block/"))) + throw error(part, "A conditional model cannot include itself"); + modelParts.add(new ConditionalModelPart( + required(part, "id"), resource(part, "model"), required(part, "when"))); + } + boolean selectable = bool(processor, "selectable", false); + if (!selectable && processor.attributes().containsKey("default-recipe")) + throw error(processor, "default-recipe requires selectable='true'"); + var definition = new MachineDefinition( id, checksum(root), required(root, "name"), @@ -474,6 +577,9 @@ else if (prior != null) resource(block, "mineable"), bool(block, "requires-tool", false), textures, + baseModel, + modelParts, + faceTextures, power == null ? null : new Power( @@ -492,7 +598,25 @@ else if (prior != null) required(processor, "id"), processor.attributes().getOrDefault("name", required(root, "name")), redstone != null && bool(redstone, "enabled", false), - new Node("screen", attrs, screen.children(), screen.source(), screen.line())); + new Node("screen", attrs, screen.children(), screen.source(), screen.line()), + selectable + ? new MachineDefinition.RecipeSelection( + processor.attributes().containsKey("default-recipe") + ? resource(processor, "default-recipe") + : null) + : null, + ghosts); + var defaults = definition.hasConditionalAppearance() + ? definition.visualDefaults() + : Map.of(); + for (var part : appearance.children()) { + try { + VisualCondition.compile(required(part, "when"), defaults); + } catch (IllegalArgumentException ex) { + throw error(part, ex.getMessage()); + } + } + return definition; } public static String required(Node node, String key) { @@ -591,9 +715,9 @@ private static Node one(Node node, String name) { return result; } - private static void requireChoice(Node node, String name, String value) { - if (!required(node, name).equals(value)) - throw error(node, "Supported " + name + ": " + value); + private static void requireChoice(Node node, String name, String... values) { + if (!List.of(values).contains(required(node, name))) + throw error(node, "Supported " + name + ": " + String.join(", ", values)); } private static boolean bool(Node node, String key, boolean fallback) { @@ -616,13 +740,20 @@ private static long number(Node node, String key, long min, long max) { throw error(node, key + " must be in " + min + ".." + max); } + private static int defaultItemColor(Map groups, IoMode mode) { + int ordinal = (int) groups.values().stream().filter(group -> group.mode() == mode).count(); + return MachineColors.item(mode, ordinal); + } + private static int color(Node node, int fallback) { var value = node.attributes().get("color"); if (value == null) return fallback; - if (!value.matches("#[0-9a-fA-F]{6}")) - throw error(node, "color must be #RRGGBB"); - return Integer.parseInt(value.substring(1), 16); + try { + return MachineColors.parse(value); + } catch (IllegalArgumentException ex) { + throw error(node, ex.getMessage()); + } } public static ScreenException error(Node node, String message) { diff --git a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java index 4d0bf42f..6154c4ff 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java @@ -65,6 +65,7 @@ public static void validate(MachineDefinition machine, Screen screen) { var inventories = new LinkedHashMap(); for (var name : machine.inventoryNames()) inventories.merge(name, 1, Integer::sum); + machine.ghosts().forEach(ghost -> inventories.put(ghost.id(), ghost.count())); var context = new ResourceValidationContext( screen, Map.of( @@ -115,7 +116,9 @@ public static Map> compileHighlights( new ElementGroup.Members( ResourceType.ENERGY.id(), machine.power().id(), List.of(0))))); if (machine.fluids() != null) - for (var group : machine.fluids().groups()) + for (var group : machine.fluids().groups()) { + if (!group.automation()) + continue; groups.add( new ElementGroup( group.id(), @@ -124,6 +127,7 @@ public static Map> compileHighlights( new ElementGroup.Members( ResourceType.FLUIDS.id(), machine.fluids().id(), group.members())))); + } var result = new LinkedHashMap>(); GroupHighlights.of(screen, groups, element -> element.common().id().isPresent()) .forEach( @@ -215,6 +219,15 @@ private static List tanks(MachineDefinition machine, Node node) { private static List groups(MachineDefinition machine, Node node, IoMode mode, int columns) { var result = new ArrayList(); + if (mode == IoMode.INPUT) + for (var ghost : machine.ghosts()) { + var children = new ArrayList(); + for (int i = 0; i < ghost.count(); i++) + children.add(n(node, "ghost-slot", Map.of("id", "ghost_" + ghost.id() + "_" + i, + "index", Integer.toString(i)), List.of())); + result.add(n(node, "inventory", + Map.of("storage", ghost.id(), "columns", Integer.toString(columns)), children)); + } for (var group : machine.groups()) if (group.mode() == mode || (mode == IoMode.BOTH && group.mode() == IoMode.DISABLED)) { var rows = new ArrayList(); diff --git a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java index 2705b26e..2041d20d 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java +++ b/src/main/java/zone/moddev/mc/lib/machine/feature/RecipeProcessor.java @@ -10,6 +10,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.RecipeHolder; import net.minecraft.world.level.Level; import org.jetbrains.annotations.Nullable; @@ -19,12 +20,18 @@ import zone.moddev.mc.lib.api.redstone.RedstoneOutput; import zone.moddev.mc.lib.api.stats.Statistics; import zone.moddev.mc.lib.api.stats.StatsProvider; +import zone.moddev.mc.lib.api.util.ChangeSignal; import zone.moddev.mc.lib.machine.inventory.EnergyGroup; import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.presentation.BuiltinVisualVariables; +import zone.moddev.mc.lib.machine.presentation.VisualVariableProvider; +import zone.moddev.mc.lib.machine.presentation.VisualVariables; import zone.moddev.mc.lib.machine.recipe.CyclePlan; import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; import zone.moddev.mc.lib.machine.recipe.MachineRecipeLookup; import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; +import zone.moddev.mc.lib.machine.recipe.MatchResult; +import zone.moddev.mc.lib.machine.recipe.RecipeMatcher; /** * Runs the recipes of one type on a machine's inventory, one cycle at a time. A cycle reserves its @@ -32,130 +39,243 @@ * takes the inputs and makes the products in one step at the end. Nothing leaves a slot or tank * before then, so a cycle that never finishes costs only the energy it used. * - *

Several processors may share an inventory; a processor sees what the others reserved. + *

+ * Several processors may share an inventory; a processor sees what the others reserved. */ public final class RecipeProcessor - implements MachineFeature, IRedstoneOutput, StatsProvider { - private static final int SEARCH_RETRY_INTERVAL = 20; - - /** What the processor is doing. */ - public enum Status { - /** No cycle. */ - IDLE, - /** A cycle is running. */ - RUNNING, - /** A cycle is waiting for energy; it keeps its progress and reservations. */ - STALLED, - /** A cycle has run its time but cannot finish, say an input was taken; retried each tick. */ - BLOCKED, - /** Held by the host's processing gate; progress and reservations are retained. */ - HELD, - /** Held by an interrupt. */ - PAUSED - } - - /** A status by name; a name it doesn't know is a decoding error, not an exception. */ - private static final Codec STATUS_CODEC = - Codec.STRING.comapFlatMap( - name -> { + implements + MachineFeature, + IRedstoneOutput, + StatsProvider, + VisualVariableProvider { + private static final int SEARCH_RETRY_INTERVAL = 20; + private final ChangeSignal activityChanges = new ChangeSignal(); + private final ChangeSignal progressChanges = new ChangeSignal(); + + public ChangeSignal activityChanges() { + return activityChanges; + } + public ChangeSignal progressChanges() { + return progressChanges; + } + + /** What the processor is doing. */ + public enum Status { + /** No cycle. */ + IDLE, + /** A cycle is running. */ + RUNNING, + /** A cycle is waiting for energy; it keeps its progress and reservations. */ + STALLED, + /** + * A cycle has run its time but cannot finish, say an input was taken; retried each tick. + */ + BLOCKED, + /** Held by the host's processing gate; progress and reservations are retained. */ + HELD, + /** Held by an interrupt. */ + PAUSED + } + + /** A status by name; a name it doesn't know is a decoding error, not an exception. */ + private static final Codec STATUS_CODEC = Codec.STRING.comapFlatMap( + name -> { try { - return DataResult.success(Status.valueOf(name)); + return DataResult.success(Status.valueOf(name)); } catch (IllegalArgumentException ex) { - return DataResult.error(() -> "Unknown processor status " + name); + return DataResult.error(() -> "Unknown processor status " + name); } - }, - Status::name); - - /** - * The saved form. A cycle that won't decode, say because an item is gone from the game, is - * dropped as a whole while the rest of the state is kept. - * - * @param paused whether an interrupt holds the processor - * @param progress ticks done of {@code cycle}, or of the recipe to resume - * @param resumeRecipe the recipe a pause or a resume is waiting to reserve again - * @param resumeTime the time that recipe took, so progress is dropped if its time changed - * @param cycle the running cycle - * @param status what the processor was doing; loading works it out again, and only a client takes - * it from here. A name this version doesn't know reads as empty, which loses nothing else. - */ - public record State( - boolean paused, - int progress, - Optional resumeRecipe, - int resumeTime, - Optional cycle, - Optional status) { - /** The save form. */ - public static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - Codec.BOOL.optionalFieldOf("paused", false).forGetter(State::paused), - Codec.intRange(0, Integer.MAX_VALUE) - .optionalFieldOf("progress", 0) - .forGetter(State::progress), - ResourceLocation.CODEC - .optionalFieldOf("resume_recipe") - .forGetter(State::resumeRecipe), - Codec.intRange(0, Integer.MAX_VALUE) - .optionalFieldOf("resume_time", 0) - .forGetter(State::resumeTime), - CyclePlan.CODEC.lenientOptionalFieldOf("cycle").forGetter(State::cycle), - STATUS_CODEC.lenientOptionalFieldOf("status").forGetter(State::status)) - .apply(instance, State::new)); - } - - private final String key; - private final String outputPrefix; - private final MachineInventory inventory; - private final MachineRecipeType type; - private final MachineRecipeLookup lookup; - @Nullable private CyclePlan cycle; - private int progress; - private boolean redstoneRunning; - private final List redstoneOutputs; - @Nullable private ResourceLocation resumeRecipe; - private int resumeTime; - private boolean paused; - private boolean changed; - private Status status = Status.IDLE; - private java.math.BigInteger completedCycles = java.math.BigInteger.ZERO; - private final Component displayName; - - /** Successful recipe completions during this processor instance's lifetime; not saved. */ - public java.math.BigInteger completedCycles() { - return completedCycles; - } - - public Component displayName() { - return displayName.copy(); - } - - private Status marked = Status.IDLE; - private int markedTime; - @Nullable private CyclePlan justFinished; - private int syncedTime; - // Failed searches retry once per second, including changes in custom groups and recipe reloads. - private int searchDelay; - private long[] searchedRevisions = new long[0]; - - /** A processor saved under {@code key} that runs recipes of {@code type} on {@code inventory}. */ - public RecipeProcessor(String key, MachineRecipeType type, MachineInventory inventory) { - this(key, type, inventory, Component.literal(key)); - } - - /** A processor with a translated display name for its redstone output entries. */ - public RecipeProcessor( - String key, MachineRecipeType type, MachineInventory inventory, Component displayName) { - this.key = key; - outputPrefix = outputPrefix(key); - this.displayName = displayName.copy(); - this.inventory = inventory; - this.type = type; - lookup = type.lookup(); - redstoneOutputs = - List.of( + }, + Status::name); + + /** + * The saved form. A cycle that won't decode, say because an item is gone from the game, is + * dropped as a whole while the rest of the state is kept. + * + * @param paused + * whether an interrupt holds the processor + * @param progress + * ticks done of {@code cycle}, or of the recipe to resume + * @param resumeRecipe + * the recipe a pause or a resume is waiting to reserve again + * @param resumeTime + * the time that recipe took, so progress is dropped if its time changed + * @param cycle + * the running cycle + * @param status + * what the processor was doing; loading works it out again, and only a client takes + * it from here. A name this version doesn't know reads as empty, which loses nothing + * else. + */ + public record State( + boolean paused, + int progress, + Optional resumeRecipe, + int resumeTime, + Optional cycle, + Optional status, + Optional selection) { + public State(boolean paused, int progress, Optional resumeRecipe, + int resumeTime, Optional cycle, Optional status) { + this(paused, progress, resumeRecipe, resumeTime, cycle, status, Optional.empty()); + } + /** The save form. */ + public static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + Codec.BOOL.optionalFieldOf("paused", false).forGetter(State::paused), + Codec.intRange(0, Integer.MAX_VALUE) + .optionalFieldOf("progress", 0) + .forGetter(State::progress), + ResourceLocation.CODEC + .optionalFieldOf("resume_recipe") + .forGetter(State::resumeRecipe), + Codec.intRange(0, Integer.MAX_VALUE) + .optionalFieldOf("resume_time", 0) + .forGetter(State::resumeTime), + CyclePlan.CODEC.lenientOptionalFieldOf("cycle").forGetter(State::cycle), + STATUS_CODEC.lenientOptionalFieldOf("status").forGetter(State::status), + Selection.CODEC.optionalFieldOf("selection").forGetter(State::selection)) + .apply(instance, State::new)); + } + + /** An explicitly empty selection differs from old saves that predate selection. */ + public record Selection(Optional recipe) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance + .group(ResourceLocation.CODEC.optionalFieldOf("recipe").forGetter(Selection::recipe)) + .apply(instance, Selection::new)); + } + + private boolean selectionEnabled; + private ResourceLocation selectedRecipe; + private ResourceLocation clientActiveRecipe; + private Object recipeSnapshot; + private long recipeRevision; + private List> choices = List + .of(); + + /** Enable explicit selection before loading saved state. Null means initially stopped. */ + public RecipeProcessor selectable(@Nullable ResourceLocation defaultRecipe) { + selectionEnabled = true; + selectedRecipe = defaultRecipe; + return this; + } + + public boolean selectionEnabled() { + return selectionEnabled; + } + public Optional selectedRecipe() { + return Optional.ofNullable(selectedRecipe); + } + public Optional activeRecipe() { + return Optional.ofNullable(cycle != null + ? cycle.recipe() + : resumeRecipe != null ? resumeRecipe : clientActiveRecipe); + } + + /** Only this processor's recipes can be selected; null explicitly stops new cycles. */ + public boolean selectRecipe(Level level, @Nullable ResourceLocation id) { + if (!selectionEnabled || level.isClientSide) + return false; + if (id != null && selectedEntry(level, id).isEmpty()) + return false; + if (!java.util.Objects.equals(selectedRecipe, id)) { + selectedRecipe = id; + searchDelay = 0; + changed = true; + activityChanges.changed(); + } + return true; + } + + public Optional> selectedEntry( + Level level, ResourceLocation id) { + var holder = level.getRecipeManager().byKey(id).orElse(null); + if (holder != null && holder.value() instanceof MachineRecipeEntry entry + && entry.type() == type) + return Optional.of(new RecipeHolder<>(id, entry)); + return Optional.empty(); + } + + /** Indexed once per recipe-manager snapshot, never rebuilt on ordinary machine ticks. */ + public List> recipeChoices( + Level level) { + var snapshot = level.getRecipeManager().getRecipes(); + if (recipeSnapshot != snapshot) { + recipeSnapshot = snapshot; + recipeRevision++; + choices = type.recipes(level.getRecipeManager()).stream() + .sorted(java.util.Comparator + .comparing(RecipeHolder::id)) + .toList(); + } + return choices; + } + + public long recipeRevision(Level level) { + recipeChoices(level); + return recipeRevision; + } + + public MatchResult recipeAvailability( + MachineRecipeEntry entry) { + return RecipeMatcher.check(entry.recipe(), inventory); + } + + private final String key; + private final String outputPrefix; + private final MachineInventory inventory; + private final MachineRecipeType type; + private final MachineRecipeLookup lookup; + @Nullable + private CyclePlan cycle; + private int progress; + private boolean redstoneRunning; + private final List redstoneOutputs; + @Nullable + private ResourceLocation resumeRecipe; + private int resumeTime; + private boolean paused; + private boolean changed; + private Status status = Status.IDLE; + private java.math.BigInteger completedCycles = java.math.BigInteger.ZERO; + private final Component displayName; + + /** Successful recipe completions during this processor instance's lifetime; not saved. */ + public java.math.BigInteger completedCycles() { + return completedCycles; + } + + public Component displayName() { + return displayName.copy(); + } + + private Status marked = Status.IDLE; + private int markedTime; + @Nullable + private CyclePlan justFinished; + private int syncedTime; + // Failed searches retry once per second, including changes in custom groups and recipe reloads. + private int searchDelay; + private long[] searchedRevisions = new long[0]; + + /** + * A processor saved under {@code key} that runs recipes of {@code type} on {@code inventory}. + */ + public RecipeProcessor(String key, MachineRecipeType type, MachineInventory inventory) { + this(key, type, inventory, Component.literal(key)); + } + + /** A processor with a translated display name for its redstone output entries. */ + public RecipeProcessor( + String key, MachineRecipeType type, MachineInventory inventory, Component displayName) { + this.key = key; + outputPrefix = outputPrefix(key); + this.displayName = displayName.copy(); + this.inventory = inventory; + this.type = type; + lookup = type.lookup(); + redstoneOutputs = List.of( output( "processing", displayName, "icon_resume", 0x64d870, () -> redstoneRunning ? 15 : 0), output("inactive", displayName, "icon_pause", 0xf0c646, () -> redstoneRunning ? 0 : 15), @@ -165,391 +285,440 @@ public RecipeProcessor( "icon_processor_progress", 0x85d2ff, this::redstoneProgress)); - } - - // Persistence keys predate redstone IDs and may contain arbitrary characters. Preserve - // existing compatible IDs; use a separate namespace for stable, bounded fallback IDs. - private static String outputPrefix(String key) { - if (key.matches("[a-z0-9_.:/-]{1,107}")) return "processor/" + key; - try { - var digest = java.security.MessageDigest.getInstance("SHA-256"); - return "processor-key/" - + java.util.HexFormat.of() - .formatHex(digest.digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8))); - } catch (java.security.NoSuchAlgorithmException impossible) { - throw new AssertionError(impossible); - } - } - - private RedstoneOutput output( - String kind, Component name, String icon, int colour, IntSupplier value) { - return new RedstoneOutput( - outputPrefix + "/" + kind, - Component.translatable("screen.mmdlib.redstone.source." + kind, name), - Component.translatable("screen.mmdlib.redstone.source." + kind + ".info", name), - colour, - Optional.of(ResourceLocation.fromNamespaceAndPath("mmdlib", icon)), - value); - } - - @Override - public List redstoneOutputs() { - return redstoneOutputs; - } - - private int redstoneProgress() { - if (justFinished != null) return 15; - int duration = cycle != null ? cycle.time() : resumeRecipe != null ? resumeTime : 0; - return duration > 0 ? RedstoneControl.level((double) progress / duration) : 0; - } - - @Override - public void suspended(FeatureHost host) { - redstoneRunning = false; - justFinished = null; - var held = paused ? Status.PAUSED : Status.HELD; - if (changed || status != held) { - changed = false; - status = held; - host.markChanged(); - } - syncStatus(host); - } - - @Override - public String key() { - return key; - } - - @Override - public Codec codec() { - return State.CODEC; - } - - @Override - public State state() { - return new State( - paused, - progress, - Optional.ofNullable(resumeRecipe), - resumeTime, - Optional.ofNullable(cycle), - Optional.of(status)); - } - - @Override - public boolean syncsToClient() { - return true; - } - - /** - * Takes on the state a client is sent: the status, whether it is paused, the progress as of the - * send and the time of the cycle. The reservations are the server's, so the cycle is not put back - * and nothing is reserved. While paused, {@link #time} is 0 and {@link #progress} is the ticks - * kept, so a progress bar should not divide by {@code time()} without checking it. - */ - @Override - public void restoreOnClient(State state) { - paused = state.paused(); - progress = state.progress(); - state.status().ifPresent(sent -> status = sent); - syncedTime = state.cycle().map(CyclePlan::time).orElse(0); - } - - /** - * Takes on saved state. A running cycle is released first, since the storages keep their plans - * when they load their contents. A saved cycle whose plan can't be put back cancels, and the - * processor goes back to searching. - * - *

Load the storages before the {@link FeatureHost} loads, so the inputs are in place for the - * plan to check. - */ - @Override - public void restore(State state) { - if (cycle != null) cycle.release(inventory); - cycle = null; - syncedTime = 0; - justFinished = null; - redstoneRunning = false; - paused = state.paused(); - searchDelay = 0; - resumeRecipe = state.resumeRecipe().orElse(null); - resumeTime = state.resumeTime(); - progress = resumeRecipe == null ? 0 : state.progress(); - var saved = state.cycle().filter(plan -> !paused && holds(plan)).orElse(null); - if (saved != null) { - cycle = saved; - progress = Math.min(state.progress(), saved.time()); - } - status = paused ? Status.PAUSED : cycle == null ? Status.IDLE : Status.RUNNING; - } - - /** Whether the plan's energy groups are there and its reservations can be put back. */ - private boolean holds(CyclePlan plan) { - for (var key : plan.energy().keySet()) - if (!(inventory.group(key).orElse(null) instanceof EnergyGroup)) return false; - return plan.restore(inventory); - } - - /** Whether an interrupt holds the processor. */ - public boolean paused() { - return paused; - } - - /** - * Actual server-tick activity, including the completion tick; false while the host is suspended. - */ - public boolean processing() { - return redstoneRunning; - } - - /** - * Pauses the processor: stops drawing energy, releases the running cycle's reservations so its - * items can be taken, and starts nothing new until {@link #resume}. Works without power and - * whatever the status. The recipe and progress are kept for the resume. - */ - public void interrupt() { - pause(); - } - - /** Pauses manually, preserving resumable progress and releasing item/fluid reservations. */ - public void pause() { - if (paused) return; - if (cycle != null) { - cycle.release(inventory); - resumeRecipe = cycle.recipe(); - resumeTime = cycle.time(); - cycle = null; - } - paused = true; - redstoneRunning = false; - status = Status.PAUSED; - justFinished = null; - changed = true; - } - - /** - * Lets a paused processor run again. The next tick reserves the paused recipe again if it still - * can, and continues its progress; otherwise progress is lost and the processor searches. - */ - public void resume() { - if (!paused) return; - paused = false; - redstoneRunning = false; - searchDelay = 0; - status = Status.IDLE; - changed = true; - } - - /** Sets the manual pause independently of redstone and other host processing gates. */ - public void setPaused(boolean value) { - if (value) pause(); - else resume(); - } - - /** Cancels work, releases reservations and forgets progress and pause state. */ - public void reset() { - if (cycle != null) cycle.release(inventory); - cycle = null; - resumeRecipe = null; - resumeTime = 0; - progress = 0; - syncedTime = 0; - justFinished = null; - paused = false; - redstoneRunning = false; - searchDelay = 0; - status = Status.IDLE; - changed = true; - } - - @Override - public Statistics statistics() { - var plan = cycle != null ? cycle : justFinished; - var maximum = - plan == null - ? new Statistics.Text(Statistics.label("no_recipe")) - : (Statistics.Value) new Statistics.Rate(java.math.BigInteger.ONE, plan.time()); - var uses = new java.util.ArrayList(); - if (plan != null) - for (var need : plan.energy().entrySet()) { - if (inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) { - long total = need.getValue(); - long peak = total / plan.time() + (total % plan.time() == 0 ? 0 : 1); - uses.add( - new Statistics.EnergyUse( - group.statisticsResource(), java.math.BigInteger.valueOf(peak))); + } + + // Persistence keys predate redstone IDs and may contain arbitrary characters. Preserve + // existing compatible IDs; use a separate namespace for stable, bounded fallback IDs. + private static String outputPrefix(String key) { + if (key.matches("[a-z0-9_.:/-]{1,107}")) + return "processor/" + key; + try { + var digest = java.security.MessageDigest.getInstance("SHA-256"); + return "processor-key/" + + java.util.HexFormat.of() + .formatHex( + digest.digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new AssertionError(impossible); + } + } + + private RedstoneOutput output( + String kind, Component name, String icon, int colour, IntSupplier value) { + return new RedstoneOutput( + outputPrefix + "/" + kind, + Component.translatable("screen.mmdlib.redstone.source." + kind, name), + Component.translatable("screen.mmdlib.redstone.source." + kind + ".info", name), + colour, + Optional.of(ResourceLocation.fromNamespaceAndPath("mmdlib", icon)), + value); + } + + @Override + public List redstoneOutputs() { + return redstoneOutputs; + } + + private int redstoneProgress() { + if (justFinished != null) + return 15; + int duration = cycle != null ? cycle.time() : resumeRecipe != null ? resumeTime : 0; + return duration > 0 ? RedstoneControl.level((double) progress / duration) : 0; + } + + @Override + public void suspended(FeatureHost host) { + redstoneRunning = false; + if (justFinished != null) + progressChanges.changed(); + justFinished = null; + var held = paused ? Status.PAUSED : Status.HELD; + if (changed || status != held) { + changed = false; + status = held; + host.markChanged(); + host.markSync(); + } + syncStatus(host); + } + + @Override + public String key() { + return key; + } + + @Override + public Codec codec() { + return State.CODEC; + } + + @Override + public State state() { + return new State( + paused, + progress, + Optional.ofNullable(resumeRecipe), + resumeTime, + Optional.ofNullable(cycle), + Optional.of(status), + selectionEnabled ? Optional.of(new Selection(selectedRecipe())) : Optional.empty()); + } + + @Override + public boolean syncsToClient() { + return true; + } + + /** + * Takes on the state a client is sent: the status, whether it is paused, the progress as of the + * send and the time of the cycle. The reservations are the server's, so the cycle is not put + * back and nothing is reserved. While paused, {@link #time} is 0 and {@link #progress} is the + * ticks kept, so a progress bar should not divide by {@code time()} without checking it. + */ + @Override + public void restoreOnClient(State state) { + state.selection().ifPresent(value -> selectedRecipe = value.recipe().orElse(null)); + clientActiveRecipe = state.cycle().map(CyclePlan::recipe) + .orElse(state.resumeRecipe().orElse(null)); + paused = state.paused(); + progress = state.progress(); + state.status().ifPresent(sent -> status = sent); + syncedTime = state.cycle().map(CyclePlan::time).orElse(0); + } + + /** + * Takes on saved state. A running cycle is released first, since the storages keep their plans + * when they load their contents. A saved cycle whose plan can't be put back cancels, and the + * processor goes back to searching. + * + *

+ * Load the storages before the {@link FeatureHost} loads, so the inputs are in place for the + * plan to check. + */ + @Override + public void restore(State state) { + if (selectionEnabled) + state.selection().ifPresent(value -> selectedRecipe = value.recipe().orElse(null)); + if (cycle != null) + cycle.release(inventory); + cycle = null; + syncedTime = 0; + justFinished = null; + redstoneRunning = false; + paused = state.paused(); + searchDelay = 0; + resumeRecipe = state.resumeRecipe().orElse(null); + resumeTime = state.resumeTime(); + progress = resumeRecipe == null ? 0 : state.progress(); + var saved = state.cycle().filter(plan -> !paused && holds(plan)).orElse(null); + if (saved != null) { + cycle = saved; + progress = Math.min(state.progress(), saved.time()); + } + status = paused ? Status.PAUSED : cycle == null ? Status.IDLE : Status.RUNNING; + activityChanges.changed(); + progressChanges.changed(); + } + + /** Whether the plan's energy groups are there and its reservations can be put back. */ + private boolean holds(CyclePlan plan) { + for (var key : plan.energy().keySet()) + if (!(inventory.group(key).orElse(null) instanceof EnergyGroup)) + return false; + return plan.restore(inventory); + } + + /** Whether an interrupt holds the processor. */ + public boolean paused() { + return paused; + } + + /** + * Actual server-tick activity, including the completion tick; false while the host is + * suspended. + */ + public boolean processing() { + return redstoneRunning; + } + + @Override + public void defineVisualVariables(VisualVariables.Builder variables) { + BuiltinVisualVariables.processor(this).defineVisualVariables(variables); + } + + /** + * Pauses the processor: stops drawing energy, releases the running cycle's reservations so its + * items can be taken, and starts nothing new until {@link #resume}. Works without power and + * whatever the status. The recipe and progress are kept for the resume. + */ + public void interrupt() { + pause(); + } + + /** Pauses manually, preserving resumable progress and releasing item/fluid reservations. */ + public void pause() { + if (paused) + return; + if (cycle != null) { + cycle.release(inventory); + resumeRecipe = cycle.recipe(); + resumeTime = cycle.time(); + cycle = null; } - } - return new Statistics( - displayName, - java.util.List.of( - Statistics.cycles("cycles", new Statistics.Counter(completedCycles, 1200)), - Statistics.cycles("cycles_max", maximum)), - uses); - } - - /** What the processor is doing. */ - public Status status() { - return status; - } - - /** Ticks of the running cycle done. */ - public int progress() { - return progress; - } - - /** - * Ticks the running cycle takes; 0 when idle, and also 0 when paused, though {@link #progress} - * then holds the kept ticks. On a client, {@code status} and this are current, and {@code - * progress} is as of the last status change. - */ - public int time() { - return cycle == null ? syncedTime : cycle.time(); - } - - /** - * Runs a tick, then asks for a sync if what clients see differs from the last thing asked for. On - * the tick that finishes a cycle the processor is idle only until the next tick starts the next - * cycle; clients are shown that tick as the cycle still running, so they never read the gap. - */ - @Override - public void tick(FeatureHost host) { - justFinished = null; - run(host); - redstoneRunning = !paused && (status == Status.RUNNING || justFinished != null); - syncStatus(host); - } - - private void syncStatus(FeatureHost host) { - var seen = justFinished != null ? Status.RUNNING : status; - int seenTime = justFinished != null ? justFinished.time() : time(); - if (seen != marked || seenTime != markedTime) { - marked = seen; - markedTime = seenTime; - host.markSync(); - } - } - - /** - * What clients are sent. It is {@link #state} except on the tick that finishes a cycle, when the - * finished cycle still shows as running so the one idle tick before the next cycle starts is not - * sent. That tick is never saved: {@link #state} says idle and holds no cycle. - */ - @Override - public State clientState() { - if (justFinished == null) return state(); - return new State( - false, - justFinished.time(), - Optional.empty(), - 0, - Optional.of(justFinished), - Optional.of(Status.RUNNING)); - } - - private void run(FeatureHost host) { - if (changed) { - changed = false; - host.markChanged(); - } - if (paused) { - status = Status.PAUSED; - return; - } - var level = host.level(); - if (cycle == null && (resumeRecipe == null || !reserveAgain(level, host))) { - start(level, host); - return; - } - if (progress < cycle.time()) { - if (!draw()) { - status = Status.STALLED; - return; - } - progress++; - status = Status.RUNNING; - host.markChanged(); - } - if (progress >= cycle.time()) { - if (cycle.finish(inventory, level.getRandom())) { - completedCycles = completedCycles.add(java.math.BigInteger.ONE); - justFinished = cycle; + paused = true; + redstoneRunning = false; + status = Status.PAUSED; + justFinished = null; + changed = true; + activityChanges.changed(); + progressChanges.changed(); + } + + /** + * Lets a paused processor run again. The next tick reserves the paused recipe again if it still + * can, and continues its progress; otherwise progress is lost and the processor searches. + */ + public void resume() { + if (!paused) + return; + paused = false; + redstoneRunning = false; + searchDelay = 0; + status = Status.IDLE; + changed = true; + activityChanges.changed(); + progressChanges.changed(); + } + + /** Sets the manual pause independently of redstone and other host processing gates. */ + public void setPaused(boolean value) { + if (value) + pause(); + else + resume(); + } + + /** Cancels work, releases reservations and forgets progress and pause state. */ + public void reset() { + if (cycle != null) + cycle.release(inventory); cycle = null; + resumeRecipe = null; + resumeTime = 0; progress = 0; + syncedTime = 0; + justFinished = null; + paused = false; + redstoneRunning = false; + searchDelay = 0; status = Status.IDLE; + changed = true; + activityChanges.changed(); + progressChanges.changed(); + } + + @Override + public Statistics statistics() { + var plan = cycle != null ? cycle : justFinished; + var maximum = plan == null + ? new Statistics.Text(Statistics.label("no_recipe")) + : (Statistics.Value) new Statistics.Rate(java.math.BigInteger.ONE, plan.time()); + var uses = new java.util.ArrayList(); + if (plan != null) + for (var need : plan.energy().entrySet()) { + if (inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) { + long total = need.getValue(); + long peak = total / plan.time() + (total % plan.time() == 0 ? 0 : 1); + uses.add( + new Statistics.EnergyUse( + group.statisticsResource(), java.math.BigInteger.valueOf(peak))); + } + } + return new Statistics( + displayName, + java.util.List.of( + Statistics.cycles("cycles", new Statistics.Counter(completedCycles, 1200)), + Statistics.cycles("cycles_max", maximum)), + uses); + } + + /** What the processor is doing. */ + public Status status() { + return status; + } + + /** Ticks of the running cycle done. */ + public int progress() { + return progress; + } + + /** + * Ticks the running cycle takes; 0 when idle, and also 0 when paused, though {@link #progress} + * then holds the kept ticks. On a client, {@code status} and this are current, and {@code + * progress} is as of the last status change. + */ + public int time() { + return cycle == null ? syncedTime : cycle.time(); + } + + /** + * Runs a tick, then asks for a sync if what clients see differs from the last thing asked for. + * On the tick that finishes a cycle the processor is idle only until the next tick starts the + * next cycle; clients are shown that tick as the cycle still running, so they never read the + * gap. + */ + @Override + public void tick(FeatureHost host) { + if (justFinished != null) + progressChanges.changed(); + justFinished = null; + run(host); + redstoneRunning = !paused && (status == Status.RUNNING || justFinished != null); + syncStatus(host); + } + + private void syncStatus(FeatureHost host) { + var seen = justFinished != null ? Status.RUNNING : status; + int seenTime = justFinished != null ? justFinished.time() : time(); + if (seen != marked || seenTime != markedTime) { + marked = seen; + markedTime = seenTime; + host.markSync(); + activityChanges.changed(); + } + } + + /** + * What clients are sent. It is {@link #state} except on the tick that finishes a cycle, when + * the finished cycle still shows as running so the one idle tick before the next cycle starts + * is not sent. That tick is never saved: {@link #state} says idle and holds no cycle. + */ + @Override + public State clientState() { + if (justFinished == null) + return state(); + return new State( + false, + justFinished.time(), + Optional.empty(), + 0, + Optional.of(justFinished), + Optional.of(Status.RUNNING), + selectionEnabled ? Optional.of(new Selection(selectedRecipe())) : Optional.empty()); + } + + private void run(FeatureHost host) { + if (changed) { + changed = false; + host.markChanged(); + host.markSync(); + } + if (paused) { + status = Status.PAUSED; + return; + } + var level = host.level(); + if (cycle == null && (resumeRecipe == null || !reserveAgain(level, host))) { + start(level, host); + return; + } + if (progress < cycle.time()) { + if (!draw()) { + status = Status.STALLED; + return; + } + progress++; + progressChanges.changed(); + status = Status.RUNNING; + host.markChanged(); + } + if (progress >= cycle.time()) { + if (cycle.finish(inventory, level.getRandom())) { + completedCycles = completedCycles.add(java.math.BigInteger.ONE); + justFinished = cycle; + cycle = null; + progress = 0; + status = Status.IDLE; + host.markChanged(); + } else { + status = Status.BLOCKED; + } + } + } + + /** + * Reserves the paused recipe again if it still exists and fits. Progress is kept only if the + * recipe takes as long as it did; if it can't be reserved, the processor searches instead. + */ + private boolean reserveAgain(Level level, FeatureHost host) { + var id = resumeRecipe; + resumeRecipe = null; host.markChanged(); - } else { - status = Status.BLOCKED; - } - } - } - - /** - * Reserves the paused recipe again if it still exists and fits. Progress is kept only if the - * recipe takes as long as it did; if it can't be reserved, the processor searches instead. - */ - private boolean reserveAgain(Level level, FeatureHost host) { - var id = resumeRecipe; - resumeRecipe = null; - host.markChanged(); - CyclePlan plan = null; - var holder = level.getRecipeManager().byKey(id).orElse(null); - if (holder != null - && holder.value() instanceof MachineRecipeEntry entry - && entry.type() == type) { - plan = - CyclePlan.of(id, entry.recipe(), inventory) - .filter(found -> found.reserve(inventory)) - .orElse(null); - } - if (plan == null) { - progress = 0; - return false; - } - cycle = plan; - if (plan.time() != resumeTime) progress = 0; - return true; - } - - /** Reserves a cycle if a recipe can run. The starting tick draws no energy. */ - private void start(Level level, FeatureHost host) { - if (searchDelay > 0 && inventory.unchanged(searchedRevisions)) { - searchDelay--; - return; - } - var found = lookup.find(level, inventory).orElse(null); - var plan = - found == null + CyclePlan plan = null; + var holder = level.getRecipeManager().byKey(id).orElse(null); + if (holder != null + && holder.value() instanceof MachineRecipeEntry entry + && entry.type() == type) { + plan = CyclePlan.of(id, entry.recipe(), inventory, true) + .filter(found -> found.reserve(inventory)) + .orElse(null); + } + if (plan == null) { + progress = 0; + progressChanges.changed(); + return false; + } + cycle = plan; + if (plan.time() != resumeTime) + progress = 0; + progressChanges.changed(); + return true; + } + + /** Reserves a cycle if a recipe can run. The starting tick draws no energy. */ + private void start(Level level, FeatureHost host) { + if (searchDelay > 0 && inventory.unchanged(searchedRevisions)) { + searchDelay--; + return; + } + var found = selectionEnabled + ? selectedRecipe == null ? null : selectedEntry(level, selectedRecipe).orElse(null) + : lookup.find(level, inventory).orElse(null); + if (selectionEnabled && found != null && !recipeAvailability(found.value()).ok()) + found = null; + var plan = found == null ? null : CyclePlan.of(found.id(), found.value().recipe(), inventory).orElse(null); - if (plan == null || !plan.reserve(inventory)) { - searchDelay = SEARCH_RETRY_INTERVAL - 1; - searchedRevisions = inventory.revisions(); - return; - } - cycle = plan; - searchDelay = 0; - progress = 0; - status = Status.RUNNING; - host.markChanged(); - } - - /** Draws this tick's energy from every energy group, or none of it. */ - private boolean draw() { - for (var need : cycle.energy().entrySet()) { - long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); - if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) - || group.stored() < share) return false; - } - try (var tx = inventory.begin()) { - for (var need : cycle.energy().entrySet()) { - long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); - if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) - || group.consume(share, tx) < share) return false; - } - tx.commit(); - return true; - } - } + if (plan == null || !plan.reserve(inventory)) { + searchDelay = SEARCH_RETRY_INTERVAL - 1; + searchedRevisions = inventory.revisions(); + return; + } + cycle = plan; + searchDelay = 0; + progress = 0; + status = Status.RUNNING; + progressChanges.changed(); + host.markChanged(); + } + + /** Draws this tick's energy from every energy group, or none of it. */ + private boolean draw() { + for (var need : cycle.energy().entrySet()) { + long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); + if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) + || group.stored() < share) + return false; + } + try (var tx = inventory.begin()) { + for (var need : cycle.energy().entrySet()) { + long share = EnergySchedule.share(need.getValue(), cycle.time(), progress); + if (!(inventory.group(need.getKey()).orElse(null) instanceof EnergyGroup group) + || group.consume(share, tx) < share) + return false; + } + tx.commit(); + return true; + } + } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java index 5c6dd0a0..8874a6e2 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java +++ b/src/main/java/zone/moddev/mc/lib/machine/ingredient/MachineIngredient.java @@ -10,6 +10,8 @@ import com.mojang.serialization.RecordBuilder; import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.netty.handler.codec.DecoderException; + import net.minecraft.core.HolderSet; import net.minecraft.core.RegistryCodecs; import net.minecraft.core.registries.Registries; @@ -19,167 +21,184 @@ import net.minecraft.world.item.crafting.Ingredient; import net.minecraft.world.level.material.Fluid; -import io.netty.handler.codec.DecoderException; - /** - * Some amount of something a machine consumes: items, fluid or energy. JSON is one of {@code - * {"items": , "count": 1}}, {@code {"fluids": "minecraft:water", "amount": 250}} or - * {@code {"energy": 4000}}. + * Some amount of something a machine requires: items, fluid or energy. JSON is one of + * {@code {"items": , "count": 1}}, {@code {"fluids": "minecraft:water", "amount": 250}} + * or {@code {"energy": 4000}}. Item and fluid inputs accept {@code "consumed": false} to reserve + * them without spending them; omitted means true. */ public sealed interface MachineIngredient permits MachineIngredient.ItemPart, MachineIngredient.FluidPart, MachineIngredient.EnergyPart { - /** The amount: an item count, mB, or FE. */ - long amount(); - - /** - * The JSON form, picked by which of {@code items}, {@code fluids} or {@code energy} is present. - */ - Codec CODEC = new Dispatch().codec(); - - /** The network form: a kind byte, then the record. Item tags arrive as the items in them. */ - StreamCodec STREAM_CODEC = - StreamCodec.of( - (buffer, ingredient) -> { + /** The amount: an item count, mB, or FE. */ + long amount(); + + /** Whether processing spends this input. Reusable item and fluid inputs are still reserved. */ + default boolean consumed() { + return true; + } + + /** + * The JSON form, picked by which of {@code items}, {@code fluids} or {@code energy} is present. + */ + Codec CODEC = new Dispatch().codec(); + + /** The network form: a kind byte, then the record. Item tags arrive as the items in them. */ + StreamCodec STREAM_CODEC = StreamCodec.of( + (buffer, ingredient) -> { switch (ingredient) { - case ItemPart part -> { - buffer.writeByte(0); - ItemPart.STREAM_CODEC.encode(buffer, part); - } - case FluidPart part -> { - buffer.writeByte(1); - FluidPart.STREAM_CODEC.encode(buffer, part); - } - case EnergyPart part -> { - buffer.writeByte(2); - EnergyPart.STREAM_CODEC.encode(buffer, part); - } + case ItemPart part -> { + buffer.writeByte(0); + ItemPart.STREAM_CODEC.encode(buffer, part); + } + case FluidPart part -> { + buffer.writeByte(1); + FluidPart.STREAM_CODEC.encode(buffer, part); + } + case EnergyPart part -> { + buffer.writeByte(2); + EnergyPart.STREAM_CODEC.encode(buffer, part); + } } - }, - buffer -> { + }, + buffer -> { int kind = buffer.readByte(); return switch (kind) { - case 0 -> ItemPart.STREAM_CODEC.decode(buffer); - case 1 -> FluidPart.STREAM_CODEC.decode(buffer); - case 2 -> EnergyPart.STREAM_CODEC.decode(buffer); - default -> throw new DecoderException("Unknown machine ingredient kind " + kind); + case 0 -> ItemPart.STREAM_CODEC.decode(buffer); + case 1 -> FluidPart.STREAM_CODEC.decode(buffer); + case 2 -> EnergyPart.STREAM_CODEC.decode(buffer); + default -> throw new DecoderException("Unknown machine ingredient kind " + kind); }; - }); - - /** - * Items matching a vanilla {@link Ingredient} (an item, a tag or a custom ingredient). - * - * @param count how many, at least 1 - */ - record ItemPart(Ingredient items, int count) implements MachineIngredient { - static final MapCodec CODEC = - RecordCodecBuilder.mapCodec( - instance -> - instance - .group( - Ingredient.CODEC_NONEMPTY.fieldOf("items").forGetter(ItemPart::items), - Codec.intRange(1, Integer.MAX_VALUE) - .optionalFieldOf("count", 1) - .forGetter(ItemPart::count)) - .apply(instance, ItemPart::new)); - static final StreamCodec STREAM_CODEC = - StreamCodec.composite( - Ingredient.CONTENTS_STREAM_CODEC, - ItemPart::items, - ByteBufCodecs.VAR_INT, - ItemPart::count, - ItemPart::new); - - @Override - public long amount() { - return count; + }); + + /** + * Items matching a vanilla {@link Ingredient} (an item, a tag or a custom ingredient). + * + * @param count + * how many, at least 1 + */ + record ItemPart(Ingredient items, int count, boolean consumed) implements MachineIngredient { + public ItemPart(Ingredient items, int count) { + this(items, count, true); + } + static final MapCodec CODEC = RecordCodecBuilder.mapCodec( + instance -> instance + .group( + Ingredient.CODEC_NONEMPTY.fieldOf("items").forGetter(ItemPart::items), + Codec.intRange(1, Integer.MAX_VALUE) + .optionalFieldOf("count", 1) + .forGetter(ItemPart::count), + Codec.BOOL.optionalFieldOf("consumed", true).forGetter(ItemPart::consumed)) + .apply(instance, ItemPart::new)); + static final StreamCodec STREAM_CODEC = StreamCodec + .composite( + Ingredient.CONTENTS_STREAM_CODEC, + ItemPart::items, + ByteBufCodecs.VAR_INT, + ItemPart::count, + ByteBufCodecs.BOOL, + ItemPart::consumed, + ItemPart::new); + + @Override + public long amount() { + return count; + } } - } - - /** - * Fluid from a set: one ID, a {@code #tag}, or a list. - * - * @param millibuckets the amount in mB, at least 1; JSON {@code amount} - */ - record FluidPart(HolderSet fluids, int millibuckets) implements MachineIngredient { - static final MapCodec CODEC = - RecordCodecBuilder.mapCodec( - instance -> - instance - .group( - RegistryCodecs.homogeneousList(Registries.FLUID) - .fieldOf("fluids") - .forGetter(FluidPart::fluids), - Codec.intRange(1, Integer.MAX_VALUE) - .fieldOf("amount") - .forGetter(FluidPart::millibuckets)) - .apply(instance, FluidPart::new)); - static final StreamCodec STREAM_CODEC = - StreamCodec.composite( - ByteBufCodecs.holderSet(Registries.FLUID), - FluidPart::fluids, - ByteBufCodecs.VAR_INT, - FluidPart::millibuckets, - FluidPart::new); - - @Override - public long amount() { - return millibuckets; + + /** + * Fluid from a set: one ID, a {@code #tag}, or a list. + * + * @param millibuckets + * the amount in mB, at least 1; JSON {@code amount} + */ + record FluidPart(HolderSet fluids, int millibuckets, + boolean consumed) implements MachineIngredient { + public FluidPart(HolderSet fluids, int millibuckets) { + this(fluids, millibuckets, true); + } + static final MapCodec CODEC = RecordCodecBuilder.mapCodec( + instance -> instance + .group( + RegistryCodecs.homogeneousList(Registries.FLUID) + .fieldOf("fluids") + .forGetter(FluidPart::fluids), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("amount") + .forGetter(FluidPart::millibuckets), + Codec.BOOL.optionalFieldOf("consumed", true).forGetter(FluidPart::consumed)) + .apply(instance, FluidPart::new)); + static final StreamCodec STREAM_CODEC = StreamCodec + .composite( + ByteBufCodecs.holderSet(Registries.FLUID), + FluidPart::fluids, + ByteBufCodecs.VAR_INT, + FluidPart::millibuckets, + ByteBufCodecs.BOOL, + FluidPart::consumed, + FluidPart::new); + + @Override + public long amount() { + return millibuckets; + } } - } - - /** - * Energy in FE; a {@code long}, so costs above 2,147,483,647 FE work. - * - * @param amount FE, at least 1 - */ - record EnergyPart(long amount) implements MachineIngredient { - static final MapCodec CODEC = - Codec.LONG + + /** + * Energy in FE; a {@code long}, so costs above 2,147,483,647 FE work. + * + * @param amount + * FE, at least 1 + */ + record EnergyPart(long amount) implements MachineIngredient { + static final MapCodec CODEC = Codec.LONG .validate( - value -> - value >= 1 - ? DataResult.success(value) - : DataResult.error(() -> "Energy must be at least 1: " + value)) + value -> value >= 1 + ? DataResult.success(value) + : DataResult.error(() -> "Energy must be at least 1: " + value)) .fieldOf("energy") .xmap(EnergyPart::new, EnergyPart::amount); - static final StreamCodec STREAM_CODEC = - ByteBufCodecs.VAR_LONG.map(EnergyPart::new, EnergyPart::amount).cast(); - } - - /** Picks the record by its key; none, or more than one, is an error naming the three keys. */ - final class Dispatch extends MapCodec { - private static final List KEYS = List.of("items", "fluids", "energy"); - - private Dispatch() {} - - @Override - public java.util.stream.Stream keys(DynamicOps ops) { - return KEYS.stream().map(ops::createString); + static final StreamCodec STREAM_CODEC = ByteBufCodecs.VAR_LONG + .map(EnergyPart::new, EnergyPart::amount).cast(); } - @Override - public DataResult decode(DynamicOps ops, MapLike input) { - var present = KEYS.stream().filter(key -> input.get(key) != null).toList(); - if (present.size() != 1) - return DataResult.error( - () -> - "A machine ingredient needs exactly one of 'items', 'fluids' or 'energy'; found " - + present); - return switch (present.getFirst()) { - case "items" -> ItemPart.CODEC.decode(ops, input).map(part -> part); - case "fluids" -> FluidPart.CODEC.decode(ops, input).map(part -> part); - default -> EnergyPart.CODEC.decode(ops, input).map(part -> part); - }; - } - - @Override - public RecordBuilder encode( - MachineIngredient input, DynamicOps ops, RecordBuilder prefix) { - return switch (input) { - case ItemPart part -> ItemPart.CODEC.encode(part, ops, prefix); - case FluidPart part -> FluidPart.CODEC.encode(part, ops, prefix); - case EnergyPart part -> EnergyPart.CODEC.encode(part, ops, prefix); - }; + /** Picks the record by its key; none, or more than one, is an error naming the three keys. */ + final class Dispatch extends MapCodec { + private static final List KEYS = List.of("items", "fluids", "energy"); + + private Dispatch() { + } + + @Override + public java.util.stream.Stream keys(DynamicOps ops) { + return KEYS.stream().map(ops::createString); + } + + @Override + public DataResult decode(DynamicOps ops, MapLike input) { + var present = KEYS.stream().filter(key -> input.get(key) != null).toList(); + if (present.size() != 1) + return DataResult.error( + () -> "A machine ingredient needs exactly one of 'items', 'fluids' or 'energy'; found " + + present); + return switch (present.getFirst()) { + case "items" -> ItemPart.CODEC.decode(ops, input).map(part -> part); + case "fluids" -> FluidPart.CODEC.decode(ops, input).map(part -> part); + default -> Codec.BOOL.optionalFieldOf("consumed", true).decode(ops, input) + .flatMap(consumed -> consumed + ? EnergyPart.CODEC.decode(ops, input).map(part -> part) + : DataResult.error( + () -> "Energy inputs cannot use consumed: false; energy is spent per tick")); + }; + } + + @Override + public RecordBuilder encode( + MachineIngredient input, DynamicOps ops, RecordBuilder prefix) { + return switch (input) { + case ItemPart part -> ItemPart.CODEC.encode(part, ops, prefix); + case FluidPart part -> FluidPart.CODEC.encode(part, ops, prefix); + case EnergyPart part -> EnergyPart.CODEC.encode(part, ops, prefix); + }; + } } - } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/GhostItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/GhostItemGroup.java new file mode 100644 index 00000000..11d1d533 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/GhostItemGroup.java @@ -0,0 +1,80 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.List; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** Saved item samples with no physical inventory or automation capability. */ +public final class GhostItemGroup implements ItemGroup { + private final ItemStack[] samples; + private long revision; + + public GhostItemGroup(int size) { + if (size < 1 || size > 54) + throw new IllegalArgumentException("Ghost group size must be 1..54"); + samples = new ItemStack[size]; + java.util.Arrays.fill(samples, ItemStack.EMPTY); + } + + public boolean set(int slot, ItemStack sample) { + if (slot < 0 || slot >= samples.length) + return false; + var next = sample.isEmpty() ? ItemStack.EMPTY : sample.copyWithCount(1); + if (!ItemStack.matches(samples[slot], next)) { + samples[slot] = next; + revision++; + } + return true; + } + + public CompoundTag save(HolderLookup.Provider registries) { + var tag = new CompoundTag(); + for (int i = 0; i < samples.length; i++) + tag.put(Integer.toString(i), + samples[i].isEmpty() ? new CompoundTag() : samples[i].save(registries)); + return tag; + } + + public void load(CompoundTag tag, HolderLookup.Provider registries) { + for (int i = 0; i < samples.length; i++) + set(i, ItemStack.parseOptional(registries, tag.getCompound(Integer.toString(i)))); + } + + public boolean ghost() { + return true; + } + public long revision() { + return revision; + } + public int size() { + return samples.length; + } + public ItemStack peek(int slot) { + return slot < 0 || slot >= size() ? ItemStack.EMPTY : samples[slot].copy(); + } + public int free(int slot) { + return peek(slot).getCount(); + } + public int extract(int slot, int count, MachineTx tx) { + return 0; + } + public int insert(ItemStack stack, MachineTx tx) { + return 0; + } + public int insert(int slot, ItemStack stack, MachineTx tx) { + return 0; + } + public boolean reserve(int slot, SlotPlan plan, MachineTx tx) { + return false; + } + public boolean restore(int slot, SlotPlan plan, MachineTx tx) { + return false; + } + public void release(int slot, SlotPlan plan, MachineTx tx) { + } + public int[][] place(List stacks, long[] taken) { + return stacks.isEmpty() ? new int[0][size()] : null; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java index a9787599..318fb813 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/ItemGroup.java @@ -2,73 +2,79 @@ import java.util.List; -import org.jetbrains.annotations.Nullable; - import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + /** Item slots in a {@link MachineInventory}. */ public non-sealed interface ItemGroup extends SlotGroup { - /** The number of slots. */ - int size(); + /** Ghost samples participate in matching only, never reservations, consumption or products. */ + default boolean ghost() { + return false; + } + /** The number of slots. */ + int size(); - /** A copy of the stack in {@code slot}; empty outside the group. */ - ItemStack peek(int slot); + /** A copy of the stack in {@code slot}; empty outside the group. */ + ItemStack peek(int slot); - /** How many items in {@code slot} no plan takes, so they are free to use; 0 outside the group. */ - int free(int slot); + /** + * How many items in {@code slot} no plan takes, so they are free to use; 0 outside the group. + */ + int free(int slot); - /** - * Takes up to {@code count} items from {@code slot}, never more than {@link #free}; returns how - * many were taken. - */ - int extract(int slot, int count, MachineTx tx); + /** + * Takes up to {@code count} items from {@code slot}, never more than {@link #free}; returns how + * many were taken. + */ + int extract(int slot, int count, MachineTx tx); - /** - * Inserts {@code stack}, onto matching stacks first and then into empty slots, without breaking - * any plan; returns how many fitted. {@code stack} itself is not changed. - */ - int insert(ItemStack stack, MachineTx tx); + /** + * Inserts {@code stack}, onto matching stacks first and then into empty slots, without breaking + * any plan; returns how many fitted. {@code stack} itself is not changed. + */ + int insert(ItemStack stack, MachineTx tx); - /** Like {@link #insert(ItemStack, MachineTx)}, but into {@code slot} only. */ - int insert(int slot, ItemStack stack, MachineTx tx); + /** Like {@link #insert(ItemStack, MachineTx)}, but into {@code slot} only. */ + int insert(int slot, ItemStack stack, MachineTx tx); - /** - * Adds {@code plan} to what is planned for {@code slot}. Returns false and changes nothing if the - * plans cannot be merged or the result is not legal for what the slot holds. Undone if {@code tx} - * closes without commit. - */ - boolean reserve(int slot, SlotPlan plan, MachineTx tx); + /** + * Adds {@code plan} to what is planned for {@code slot}. Returns false and changes nothing if + * the plans cannot be merged or the result is not legal for what the slot holds. Undone if + * {@code tx} closes without commit. + */ + boolean reserve(int slot, SlotPlan plan, MachineTx tx); - /** - * Adds {@code plan} to the slot's plan without checking that it is legal, for loading plans that - * were saved while legal. Returns false and changes nothing if slot is outside the group or the - * plans cannot be merged. - */ - boolean restore(int slot, SlotPlan plan, MachineTx tx); + /** + * Adds {@code plan} to the slot's plan without checking that it is legal, for loading plans + * that were saved while legal. Returns false and changes nothing if slot is outside the group + * or the plans cannot be merged. + */ + boolean restore(int slot, SlotPlan plan, MachineTx tx); - /** Takes {@code plan}, which must have been reserved, out of the slot's plan. */ - void release(int slot, SlotPlan plan, MachineTx tx); + /** Takes {@code plan}, which must have been reserved, out of the slot's plan. */ + void release(int slot, SlotPlan plan, MachineTx tx); - /** Whether all of {@code stacks} would fit together. Works on copies; changes nothing. */ - default boolean fits(List stacks) { - return fits(stacks, new long[size()]); - } + /** Whether all of {@code stacks} would fit together. Works on copies; changes nothing. */ + default boolean fits(List stacks) { + return fits(stacks, new long[size()]); + } - /** - * Whether all of {@code stacks} would fit together once {@code taken[slot]} items are gone from - * each slot. Works on copies; changes nothing. - */ - default boolean fits(List stacks, long[] taken) { - return place(stacks, taken) != null; - } + /** + * Whether all of {@code stacks} would fit together once {@code taken[slot]} items are gone from + * each slot. Works on copies; changes nothing. + */ + default boolean fits(List stacks, long[] taken) { + return place(stacks, taken) != null; + } - /** - * Where {@code stacks} would land once {@code taken[slot]} items are gone from each slot: per - * stack, per slot, how many go there. A slot's end state decides what may join it and how much - * room planned takes and adds leave, but items another plan has yet to take still fill the slot - * now, and a different item waits until the slot has physically emptied. A slot whose plan is - * not legal takes nothing. Null if they do not all fit. Works on copies; changes nothing. - */ - @Nullable - int[][] place(List stacks, long[] taken); + /** + * Where {@code stacks} would land once {@code taken[slot]} items are gone from each slot: per + * stack, per slot, how many go there. A slot's end state decides what may join it and how much + * room planned takes and adds leave, but items another plan has yet to take still fill the slot + * now, and a different item waits until the slot has physically emptied. A slot whose plan is + * not legal takes nothing. Null if they do not all fit. Works on copies; changes nothing. + */ + @Nullable + int[][] place(List stacks, long[] taken); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java index 2ec7e7fc..f11a83db 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java @@ -4,16 +4,19 @@ /** Save keys owned by machine persistence/sync rather than consumer storage. */ public final class MachineStorageKeys { - public static final Set RESERVED = - Set.of( - "automation", - "features", - "redstone_control", - "redstone_mode", - "id", - "components", - "mmdlib_features", - "mmdlib_machine"); + public static final Set RESERVED = Set.of( + "automation", + "features", + "redstone_control", + "redstone_mode", + "id", + "components", + "mmdlib_features", + "mmdlib_machine", + "mmdlib_ghost_items", + "multiblock", + "multiblock_view"); - private MachineStorageKeys() {} + private MachineStorageKeys() { + } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/FixedLayout.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/FixedLayout.java new file mode 100644 index 00000000..7efa0428 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/FixedLayout.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.List; +import java.util.Map; + +/** Fixed candidate producer. Validation and placement are separate from resolving the layout. */ +public record FixedLayout(ResolvedStructure structure) implements StructureResolver { + public FixedLayout(List members, List requirements) { + this(new ResolvedStructure(members, requirements, Map.of())); + } + @Override + public Resolution resolve(WorldView world, StructureTransform transform) { + return Resolution.found(structure); + } + @Override + public boolean accepts(ResolvedStructure candidate) { + return structure.equals(candidate); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/LinkedMember.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/LinkedMember.java new file mode 100644 index 00000000..eafeb64d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/LinkedMember.java @@ -0,0 +1,7 @@ +package zone.moddev.mc.lib.machine.multiblock; + +/** Small ownership record on a physical part. Null means unclaimed. */ +public interface LinkedMember { + MemberLink memberLink(); + void memberLink(MemberLink link); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MemberLink.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MemberLink.java new file mode 100644 index 00000000..47fabe25 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MemberLink.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.UUID; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; + +/** Persisted reciprocal ownership, never a live controller reference. */ +public record MemberLink(ResourceLocation dimension, BlockPos controller, UUID formation, + String member) { + public MemberLink { + java.util.Objects.requireNonNull(dimension); + java.util.Objects.requireNonNull(formation); + controller = controller.immutable(); + if (member.isBlank() || member.length() > 128) + throw new IllegalArgumentException("Invalid member key"); + } + public CompoundTag save() { + var tag = new CompoundTag(); + tag.putString("dimension", dimension.toString()); + tag.putLong("controller", controller.asLong()); + tag.putUUID("formation", formation); + tag.putString("member", member); + return tag; + } + public static MemberLink load(CompoundTag tag) { + return new MemberLink(ResourceLocation.parse(tag.getString("dimension")), + BlockPos.of(tag.getLong("controller")), tag.getUUID("formation"), + tag.getString("member")); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockController.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockController.java new file mode 100644 index 00000000..77398479 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockController.java @@ -0,0 +1,6 @@ +package zone.moddev.mc.lib.machine.multiblock; + +/** Optional composition contract for any block entity. */ +public interface MultiblockController { + MultiblockInstance multiblock(); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockDefinition.java new file mode 100644 index 00000000..b3907eef --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockDefinition.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; + +/** Consumer-owned Java definition. No global registry or assembly-mode-specific catalog. */ +public record MultiblockDefinition(ResourceLocation id, int version, StructureResolver resolver, + int maxMembers, int maxExtent, Dismantling dismantling, + Map rules, List ports) { + public enum Dismantling { + REMOVE_OWNED, LEAVE_BLOCKS + } + @FunctionalInterface + public interface RequirementRule { + boolean test(Level level, BlockPos pos); + } + public MultiblockDefinition { + Objects.requireNonNull(id); + Objects.requireNonNull(resolver); + Objects.requireNonNull(dismantling); + if (version < 1 || maxMembers < 1 || maxMembers > 4096 || maxExtent < 1 || maxExtent > 128) + throw new IllegalArgumentException("Invalid definition limits"); + rules = Map.copyOf(rules); + ports = List.copyOf(ports); + if (ports.size() > 256) + throw new IllegalArgumentException("Too many ports"); + } + public void validate(ResolvedStructure structure) { + if (structure.members().size() > maxMembers || !resolver.accepts(structure)) + throw new IllegalArgumentException("Incompatible structure manifest"); + for (var member : structure.members()) + checkOffset(member.offset()); + for (var requirement : structure.requirements()) { + checkOffset(requirement.offset()); + if (!rules.containsKey(requirement.rule())) + throw new IllegalArgumentException("Unknown requirement"); + } + var bindings = new java.util.HashSet(); + for (var port : ports) { + structure.member(port.member()); + for (var face : DirectionKeys.of(port)) + if (!bindings.add(port.member() + "/" + port.kind() + "/" + face)) + throw new IllegalArgumentException("Ambiguous port face"); + } + } + private void checkOffset(BlockPos pos) { + if (Math.abs((long) pos.getX()) > maxExtent || Math.abs((long) pos.getY()) > maxExtent + || Math.abs((long) pos.getZ()) > maxExtent) + throw new IllegalArgumentException("Structure extent exceeded"); + } + private static final class DirectionKeys { + static List of(StructurePort port) { + var keys = new java.util.ArrayList(); + port.faces().forEach(face -> keys.add(face.getName())); + if (port.unsided()) + keys.add("unsided"); + return keys; + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java new file mode 100644 index 00000000..863f2847 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java @@ -0,0 +1,432 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Objects; +import java.util.UUID; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** One controller's committed formation. Placement is deliberately outside this lifecycle. */ +public final class MultiblockInstance { + public enum Status { + UNFORMED, READY, SUSPENDED, DISASSEMBLING + } + public enum Cause { + SURVIVAL, CREATIVE, EXPLOSION, RECOVERY, INVALIDATED + } + public interface Listener { + default void changed(Status previous, Status current, String reason) { + } + /** Runs once, before owned blocks are removed; the consumer settles contents and loot. */ + default void dismantling(Cause cause) { + } + } + + private final BlockEntity owner; + private final MultiblockDefinition definition; + private final StructureMembership membership; + private final Listener listener; + private ResolvedStructure structure; + private java.util.Map memberIndex = java.util.Map.of(); + private StructureTransform transform; + private UUID formation; + private Status status = Status.UNFORMED; + private String diagnostic = "unformed"; + private boolean committed; + private boolean changing; + private long revision; + private CompoundTag incompatible; + + public MultiblockInstance(BlockEntity owner, MultiblockDefinition definition, + StructureMembership membership, Listener listener) { + this.owner = Objects.requireNonNull(owner); + this.definition = Objects.requireNonNull(definition); + this.membership = Objects.requireNonNull(membership); + this.listener = Objects.requireNonNull(listener); + } + public BlockEntity owner() { + return owner; + } + public MultiblockDefinition definition() { + return definition; + } + public ResolvedStructure structure() { + return structure; + } + public StructureTransform transform() { + return transform; + } + public UUID formation() { + return formation; + } + public long revision() { + return revision; + } + public Status status() { + return status; + } + public String diagnostic() { + return diagnostic; + } + public boolean changing() { + return changing; + } + public boolean committed() { + return committed; + } + + /** Prepares an inactive manifest before a placement attempt writes its remaining members. */ + public boolean stage(ResolvedStructure candidate, Direction facing) { + var level = owner.getLevel(); + if (level == null || level.isClientSide || changing || formation != null + || incompatible != null) + return false; + definition.validate(candidate); + structure = candidate; + memberIndex = candidate.members().stream().collect( + java.util.stream.Collectors.toUnmodifiableMap(StructureMember::key, member -> member)); + transform = new StructureTransform(owner.getBlockPos(), facing); + formation = UUID.randomUUID(); + revision++; + owner.setChanged(); + return true; + } + + /** Formation of existing members: no placement writes and no item consumption. */ + public boolean form(ResolvedStructure candidate, Direction facing) { + if (!stage(candidate, facing)) + return false; + if (commit()) + return true; + discard(); + return false; + } + + /** Claims an existing candidate and activates only after all reciprocal checks succeed. */ + public boolean commit() { + var level = owner.getLevel(); + if (level == null || level.isClientSide || changing || structure == null || committed) + return false; + changing = true; + var claimed = new ArrayList(); + try { + for (var member : structure.members()) { + var pos = transform.position(member.offset()); + if (!level.hasChunkAt(pos) || !level.getBlockState(pos) + .equals(member.state().rotate(transform.rotation()))) + return false; + var previous = membership.link(level, pos); + if (previous != null && !previous.equals(link(member))) + return false; + if (!membership.claim(level, pos, link(member))) + return false; + claimed.add(member); + } + for (var rule : structure.requirements()) { + var pos = transform.position(rule.offset()); + if (!level.hasChunkAt(pos) || !definition.rules().get(rule.rule()).test(level, pos)) + return false; + } + if (!matchesAll()) + return false; + committed = true; + owner.setChanged(); + } finally { + if (!committed) + for (var member : claimed) + membership.release(level, transform.position(member.offset()), link(member)); + changing = false; + } + transition(Status.READY, "formed"); + return true; + } + + /** Abandons an uncommitted attempt without machine loot or world writes. */ + public void discard() { + if (committed) + throw new IllegalStateException("Cannot discard a committed machine"); + structure = null; + transform = null; + formation = null; + owner.setChanged(); + } + + public MemberLink memberLink(String key) { + return link(structure.member(key)); + } + + public MemberLink controllerLink() { + if (structure == null || formation == null || owner.getLevel() == null) + return null; + return link(structure.members().stream().filter(m -> m.role().equals("controller")) + .findFirst().orElseThrow()); + } + + private MemberLink link(StructureMember member) { + return new MemberLink(owner.getLevel().dimension().location(), owner.getBlockPos(), + formation, member.key()); + } + + /** Reciprocal check also used by stale cached handlers and remote menu anchors. */ + public boolean owns(BlockPos pos, MemberLink expected) { + if (!contains(pos, expected)) + return false; + var member = memberIndex.get(expected.member()); + return owner.getLevel().getBlockState(pos) + .equals(member.state().rotate(transform.rotation())) + && expected.equals(membership.link(owner.getLevel(), pos)); + } + + /** Checks the manifest identity/position even while a block removal is changing its state. */ + public boolean contains(BlockPos pos, MemberLink expected) { + var level = owner.getLevel(); + if (level == null || owner.isRemoved() || structure == null || formation == null + || expected == null + || !formation.equals(expected.formation()) + || !expected.controller().equals(owner.getBlockPos()) + || !expected.dimension().equals(level.dimension().location()) || !level.hasChunkAt(pos)) + return false; + var member = memberIndex.get(expected.member()); + return member != null && transform.position(member.offset()).equals(pos); + } + + private boolean matchesAll() { + for (var member : structure.members()) + if (!owns(transform.position(member.offset()), link(member))) + return false; + return true; + } + + /** Bounded fixed-layout validation. No discovery or forced chunk loading occurs here. */ + public void tick() { + var level = owner.getLevel(); + if (level == null || level.isClientSide || changing || structure == null) + return; + if (!committed) { + dismantle(Cause.RECOVERY); + return; + } + for (var member : structure.members()) { + if (!level.hasChunkAt(transform.position(member.offset()))) { + transition(Status.SUSPENDED, "unloaded_member:" + member.key()); + return; + } + } + for (var rule : structure.requirements()) { + if (rule.continuous() && !level.hasChunkAt(transform.position(rule.offset()))) { + transition(Status.SUSPENDED, "unloaded_requirement:" + rule.rule()); + return; + } + } + if (!matchesAll()) { + dismantle(Cause.RECOVERY); + return; + } + for (var rule : structure.requirements()) { + if (rule.continuous() && !definition.rules().get(rule.rule()).test(level, + transform.position(rule.offset()))) { + transition(Status.SUSPENDED, "obstructed:" + rule.rule()); + return; + } + } + transition(Status.READY, "ready"); + } + + public boolean ready() { + tick(); + return committed && !changing && status == Status.READY && !owner.isRemoved(); + } + + public void dismantle(Cause cause) { + var level = owner.getLevel(); + if (level == null || level.isClientSide || changing || structure == null) + return; + changing = true; + boolean wasCommitted = committed; + committed = false; + revision++; + try { + transition(Status.DISASSEMBLING, cause.name().toLowerCase(java.util.Locale.ROOT)); + if (wasCommitted) + listener.dismantling(cause); + // Controller last: keep the owner available while linked parts are removed. + var ordered = new ArrayList<>(structure.members()); + ordered + .sort(java.util.Comparator.comparing(member -> member.role().equals("controller"))); + for (var member : ordered) { + var pos = transform.position(member.offset()); + if (!level.hasChunkAt(pos) || !owns(pos, link(member))) + continue; + membership.release(level, pos, link(member)); + if (definition.dismantling() == MultiblockDefinition.Dismantling.REMOVE_OWNED) + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + structure = null; + transform = null; + formation = null; + transition(Status.UNFORMED, "dismantled"); + owner.setChanged(); + } finally { + changing = false; + } + } + + private void transition(Status next, String reason) { + var previous = status; + boolean changed = previous != next || !diagnostic.equals(reason); + status = next; + diagnostic = reason; + if (changed) { + owner.setChanged(); + var level = owner.getLevel(); + if (level != null) { + if (structure != null) + for (var member : structure.members()) + membership.availabilityChanged(level, transform.position(member.offset())); + level.sendBlockUpdated(owner.getBlockPos(), owner.getBlockState(), + owner.getBlockState(), 2); + } + } + if (previous != next) + listener.changed(previous, next, reason); + } + + public CompoundTag save() { + if (incompatible != null) + return incompatible.copy(); + var tag = new CompoundTag(); + tag.putInt("schema", 1); + if (structure == null) + return tag; + tag.putString("definition", definition.id().toString()); + tag.putInt("version", definition.version()); + tag.putString("policy", definition.dismantling().name()); + tag.putUUID("formation", formation); + tag.putString("facing", transform.facing().getName()); + tag.putBoolean("committed", committed); + tag.putLong("revision", revision); + var members = new ListTag(); + for (var member : structure.members()) { + var value = new CompoundTag(); + value.putString("key", member.key()); + value.putString("role", member.role()); + value.putLong("offset", member.offset().asLong()); + value.put("state", NbtUtils.writeBlockState(member.state())); + members.add(value); + } + tag.put("members", members); + var rules = new ListTag(); + for (var rule : structure.requirements()) { + var value = new CompoundTag(); + value.putString("rule", rule.rule()); + value.putLong("offset", rule.offset().asLong()); + value.putBoolean("continuous", rule.continuous()); + rules.add(value); + } + tag.put("requirements", rules); + var measurements = new CompoundTag(); + structure.measurements().forEach(measurements::putInt); + tag.put("measurements", measurements); + return tag; + } + + /** World-save restoration only. Malformed/incompatible manifests are preserved but inactive. */ + public void load(CompoundTag tag, net.minecraft.core.HolderLookup.Provider registries) { + structure = null; + transform = null; + formation = null; + committed = false; + incompatible = null; + status = Status.UNFORMED; + diagnostic = "unformed"; + if (tag.isEmpty()) + return; + try { + if (tag.getInt("schema") != 1) + throw new IllegalArgumentException("Unknown schema"); + if (!tag.contains("definition")) + return; + if (!tag.getString("definition").equals(definition.id().toString()) + || tag.getInt("version") != definition.version() + || !tag.getString("policy").equals(definition.dismantling().name())) + throw new IllegalArgumentException("Definition changed"); + var entries = tag.getList("members", Tag.TAG_COMPOUND); + var requirements = tag.getList("requirements", Tag.TAG_COMPOUND); + if (entries.size() > definition.maxMembers() || requirements.size() > 4096) + throw new IllegalArgumentException("Manifest too large"); + var members = new ArrayList(); + for (var entry : entries) { + var value = (CompoundTag) entry; + members.add(new StructureMember(value.getString("key"), value.getString("role"), + BlockPos.of(value.getLong("offset")), NbtUtils.readBlockState( + registries.lookupOrThrow(Registries.BLOCK), value.getCompound("state")))); + } + var rules = new ArrayList(); + for (var entry : requirements) { + var value = (CompoundTag) entry; + rules.add(new StructureRequirement(value.getString("rule"), + BlockPos.of(value.getLong("offset")), value.getBoolean("continuous"))); + } + var measurements = new LinkedHashMap(); + var values = tag.getCompound("measurements"); + if (values.size() > 64) + throw new IllegalArgumentException("Too many measurements"); + for (var key : values.getAllKeys()) + measurements.put(key, values.getInt(key)); + var candidate = new ResolvedStructure(members, rules, measurements); + definition.validate(candidate); + var facing = Direction.byName(tag.getString("facing")); + transform = new StructureTransform(owner.getBlockPos(), Objects.requireNonNull(facing)); + formation = tag.getUUID("formation"); + revision = tag.getLong("revision"); + committed = tag.getBoolean("committed"); + structure = candidate; + memberIndex = candidate.members().stream().collect(java.util.stream.Collectors + .toUnmodifiableMap(StructureMember::key, member -> member)); + status = Status.SUSPENDED; + diagnostic = "awaiting_validation"; + } catch (RuntimeException failure) { + structure = null; + transform = null; + formation = null; + committed = false; + incompatible = tag.copy(); + diagnostic = "incompatible_manifest"; + } + } + + public CompoundTag clientState() { + var tag = new CompoundTag(); + tag.putString("status", status.name()); + tag.putString("diagnostic", diagnostic); + if (formation != null) { + tag.putUUID("formation", formation); + tag.putString("facing", transform.facing().getName()); + } + return tag; + } + public void readClientState(CompoundTag tag) { + if (owner.getLevel() != null && !owner.getLevel().isClientSide) + return; + try { + status = Status.valueOf(tag.getString("status")); + } catch (IllegalArgumentException ignored) { + status = Status.UNFORMED; + } + diagnostic = tag.getString("diagnostic"); + formation = tag.hasUUID("formation") ? tag.getUUID("formation") : null; + var facing = Direction.byName(tag.getString("facing")); + transform = facing != null && facing.getAxis().isHorizontal() + ? new StructureTransform(owner.getBlockPos(), facing) + : null; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/ResolvedStructure.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/ResolvedStructure.java new file mode 100644 index 00000000..5d14879d --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/ResolvedStructure.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import net.minecraft.core.BlockPos; + +/** Actual membership, independent of placement and capable of representing discovered shapes. */ +public record ResolvedStructure(List members, + List requirements, Map measurements) { + public ResolvedStructure { + members = List.copyOf(members); + requirements = List.copyOf(requirements); + measurements = Map.copyOf(measurements); + if (members.isEmpty() || members.size() > 4096 || requirements.size() > 4096 + || measurements.size() > 64) + throw new IllegalArgumentException("Structure limits exceeded"); + var keys = new HashSet(); + var positions = new HashSet(); + int controllers = 0; + for (var member : members) { + if (!keys.add(member.key()) || !positions.add(member.offset())) + throw new IllegalArgumentException("Duplicate member key/position"); + if (member.role().equals("controller")) { + controllers++; + if (!member.offset().equals(BlockPos.ZERO)) + throw new IllegalArgumentException("Controller must be at origin"); + } + } + if (controllers != 1) + throw new IllegalArgumentException("Exactly one controller required"); + measurements.forEach((key, value) -> { + if (key.isBlank() || key.length() > 128 || value < 0) + throw new IllegalArgumentException("Invalid measurement"); + }); + } + + public StructureMember member(String key) { + return members.stream().filter(member -> member.key().equals(key)).findFirst() + .orElseThrow(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMember.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMember.java new file mode 100644 index 00000000..bb0f98c5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMember.java @@ -0,0 +1,18 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.Objects; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** A physical member, with a stable key and a state authored facing north. */ +public record StructureMember(String key, String role, BlockPos offset, BlockState state) { + public StructureMember { + if (key.isBlank() || key.length() > 128 || role.isBlank() || role.length() > 128) + throw new IllegalArgumentException("Invalid member key/role"); + offset = offset.immutable(); + Objects.requireNonNull(state); + if (state.isAir()) + throw new IllegalArgumentException("Air is a requirement, not a member"); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMembership.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMembership.java new file mode 100644 index 00000000..f1de0927 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureMembership.java @@ -0,0 +1,13 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; + +/** Ownership seam for future passive casing indexes; phase 1 uses linked block entities. */ +public interface StructureMembership { + MemberLink link(Level level, BlockPos pos); + boolean claim(Level level, BlockPos pos, MemberLink link); + void release(Level level, BlockPos pos, MemberLink expected); + default void availabilityChanged(Level level, BlockPos pos) { + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructurePort.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructurePort.java new file mode 100644 index 00000000..649b0c88 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructurePort.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import java.util.Set; + +import net.minecraft.core.Direction; + +/** Port permissions narrow an already-filtered controller resource view. */ +public record StructurePort(String member, String resource, Kind kind, Set faces, + boolean unsided, boolean input, boolean output) { + public enum Kind { + ITEMS, FLUIDS, ENERGY + } + public StructurePort { + if (member.isBlank() || resource.isBlank() || member.length() > 128 + || resource.length() > 128) + throw new IllegalArgumentException("Invalid port binding"); + java.util.Objects.requireNonNull(kind); + faces = Set.copyOf(faces); + } + public boolean exposes(Direction local) { + return local == null ? unsided : faces.contains(local); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureRequirement.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureRequirement.java new file mode 100644 index 00000000..61dc21b7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureRequirement.java @@ -0,0 +1,12 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import net.minecraft.core.BlockPos; + +/** A named definition-owned rule; required space is never owned or removed. */ +public record StructureRequirement(String rule, BlockPos offset, boolean continuous) { + public StructureRequirement { + if (rule.isBlank() || rule.length() > 128) + throw new IllegalArgumentException("Invalid rule"); + offset = offset.immutable(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureResolver.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureResolver.java new file mode 100644 index 00000000..59c74098 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureResolver.java @@ -0,0 +1,30 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** Read-only discovery seam. Phase 2 resolvers may discover bounded, variable-size membership. */ +public interface StructureResolver { + Resolution resolve(WorldView world, StructureTransform transform); + + /** Reject incompatible saved manifests without rewriting the surrounding world. */ + boolean accepts(ResolvedStructure structure); + + interface WorldView { + boolean loaded(BlockPos pos); + BlockState state(BlockPos pos); + } + + record Resolution(ResolvedStructure structure, String diagnostic) { + public Resolution { + if (diagnostic == null || diagnostic.length() > 512) + throw new IllegalArgumentException("Invalid diagnostic"); + } + public static Resolution found(ResolvedStructure structure) { + return new Resolution(java.util.Objects.requireNonNull(structure), ""); + } + public static Resolution refused(String diagnostic) { + return new Resolution(null, diagnostic); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureTransform.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureTransform.java new file mode 100644 index 00000000..706caac4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/StructureTransform.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.phys.AABB; + +/** Coordinates are authored facing north; the controller occupies the origin. */ +public record StructureTransform(BlockPos origin, Direction facing) { + public StructureTransform { + origin = origin.immutable(); + if (facing.getAxis().isVertical()) + throw new IllegalArgumentException("Horizontal facing required"); + } + + public Rotation rotation() { + return switch (facing) { + case EAST -> Rotation.CLOCKWISE_90; + case SOUTH -> Rotation.CLOCKWISE_180; + case WEST -> Rotation.COUNTERCLOCKWISE_90; + default -> Rotation.NONE; + }; + } + + public BlockPos position(BlockPos offset) { + return origin.offset(offset.rotate(rotation())); + } + + public Direction direction(Direction local) { + return rotation().rotate(local); + } + + public Direction local(Direction world) { + for (var direction : Direction.values()) + if (direction(direction) == world) + return direction; + throw new IllegalArgumentException("Unknown direction"); + } + + public AABB bounds(ResolvedStructure structure) { + AABB bounds = new AABB(origin); + for (var member : structure.members()) + bounds = bounds.minmax(new AABB(position(member.offset()))); + return bounds; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java index 753659b3..90e7b07e 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java @@ -41,7 +41,8 @@ public abstract class BaseMachine extends BlockEntity implements ScreenHost, DropsContents, - Clearable { + Clearable, + zone.moddev.mc.lib.machine.multiblock.MultiblockController { public static final ResourceLocation SCREEN_TEMPLATE = ResourceLocation .parse("mmdlib:base_machine"); private static final String CLIENT_DATA = "mmdlib_machine"; @@ -53,6 +54,38 @@ protected BaseMachine(BlockEntityType type, BlockPos pos, BlockState state) { super(type, pos, state); } + /** Optional composed structure; existing single-block machines return null. */ + @Override + public zone.moddev.mc.lib.machine.multiblock.MultiblockInstance multiblock() { + return null; + } + + /** Also gates consumer-owned push operations and custom before/after tick work. */ + public final boolean structureReady() { + return multiblock() == null || multiblock().ready(); + } + + @Override + public boolean screenInteractionAnchor(BlockPos anchor) { + if (anchor.equals(worldPosition)) + return true; + if (level != null && level.isClientSide && multiblock() != null) { + var link = MultiblockMembers.INSTANCE.link(level, anchor); + return link != null && link.controller().equals(worldPosition) + && link.dimension().equals(level.dimension().location()) + && link.formation().equals(multiblock().formation()); + } + return multiblock() != null && level != null && level.hasChunkAt(anchor) + && multiblock().owns(anchor, MultiblockMembers.INSTANCE.link(level, anchor)); + } + + @Override + public String screenInteractionIdentity(BlockPos anchor) { + return multiblock() == null || anchor.equals(worldPosition) + ? "" + : String.valueOf(multiblock().formation()); + } + /** Optional power storage; fluid-fuel and unpowered machines return null. */ protected EnergyBuffer powerStorage() { return null; @@ -170,17 +203,26 @@ protected void afterMachineTick() { public final void serverTick() { if (level == null || level.isClientSide) return; + if (multiblock() != null) + multiblock().tick(); + if (isRemoved()) + return; + boolean structureReady = structureReady(); var redstone = screenRedstone(); if (redstone != null) redstone.readInputs(); - beforeMachineTick(); + if (structureReady) + beforeMachineTick(); boolean paused = redstone != null && redstone.paused(); var inventory = configuredInventory(); if (inventory != null) - inventory.tick(processingAllowed(), paused, pausePolicy()); + inventory.tick(structureReady && processingAllowed(), paused, pausePolicy(), + structureReady); else - features.tick(level, processingAllowed() && pausePolicy().processingEnabled(paused)); - afterMachineTick(); + features.tick(level, + structureReady && processingAllowed() && pausePolicy().processingEnabled(paused)); + if (structureReady) + afterMachineTick(); if (redstone != null) redstone.updateOutputs(); } @@ -235,14 +277,18 @@ protected final void saveAdditional(CompoundTag tag, HolderLookup.Provider regis if (redstone != null) tag.put("redstone_control", redstone.save(registries)); saveMachineData(tag, registries); + if (multiblock() != null) + tag.put("multiblock", multiblock().save()); } @Override protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { if (FeatureSync.read(features, tag, registries)) { + if (multiblock() != null && (level == null || level.isClientSide)) + multiblock().readClientState(tag.getCompound("multiblock_view")); var redstone = screenRedstone(); if (redstone != null && tag.contains("redstone_control", Tag.TAG_COMPOUND)) - redstone.load(tag.getCompound("redstone_control"), registries); + redstone.loadClient(tag.getCompound("redstone_control"), registries); if (tag.contains(CLIENT_DATA, Tag.TAG_COMPOUND)) loadClientData(tag.getCompound(CLIENT_DATA), registries); return; @@ -269,6 +315,8 @@ protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider regis } } loadMachineData(tag, registries); + if (multiblock() != null) + multiblock().load(tag.getCompound("multiblock"), registries); } @Override @@ -276,10 +324,12 @@ public final CompoundTag getUpdateTag(HolderLookup.Provider registries) { var tag = FeatureSync.updateTag(features, registries); var redstone = screenRedstone(); if (redstone != null) - tag.put("redstone_control", redstone.save(registries)); + tag.put("redstone_control", redstone.saveClient(registries)); var clientData = new CompoundTag(); saveClientData(clientData, registries); tag.put(CLIENT_DATA, clientData); + if (multiblock() != null) + tag.put("multiblock_view", multiblock().clientState()); return tag; } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java index ccfcd56d..8317b250 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java @@ -48,6 +48,13 @@ protected void createBlockStateDefinition(StateDefinition.Builder ghosts = new java.util.LinkedHashMap<>(); public DefinedMachineEntity( BlockEntityType type, @@ -94,6 +99,11 @@ public DefinedMachineEntity( energy); if (definition.fluids() != null) for (var group : definition.fluids().groups()) { + if (!group.automation()) { + resources.recipeOnlyGroup(group.id(), StorageGroups.fluids(fluids, + group.members().stream().mapToInt(Integer::intValue).toArray())); + continue; + } resources.fluids( new ResourceGroup<>( group.id(), @@ -111,18 +121,154 @@ public DefinedMachineEntity( for (var group : definition.groups()) if (group.mode() == IoMode.INPUT) resources.recipeInputs(recipes, group.id()); + for (var ghost : definition.ghosts()) { + var samples = new zone.moddev.mc.lib.machine.inventory.GhostItemGroup(ghost.count()); + if (ghost.defaultItem() != null) { + if (!BuiltInRegistries.ITEM.containsKey(ghost.defaultItem())) + throw new IllegalArgumentException( + "Unknown ghost default item " + ghost.defaultItem()); + for (int i = 0; i < ghost.count(); i++) + samples.set(i, new net.minecraft.world.item.ItemStack( + BuiltInRegistries.ITEM.get(ghost.defaultItem()))); + } + ghosts.put(ghost.id(), samples); + resources.recipeOnlyGroup(ghost.id(), samples); + } resources.build(); - features.add( - new RecipeProcessor( - definition.processor(), - recipes, - resources.recipes(), - Component.literal(definition.processorName()))); + var processor = new RecipeProcessor( + definition.processor(), + recipes, + resources.recipes(), + Component.literal(definition.processorName())); + if (definition.recipeSelection() != null) + processor.selectable(definition.recipeSelection().defaultRecipe()); + features.add(processor); inventory = genericInventory(resources.automation()).items(definition.itemStorageId(), items); if (definition.fluids() != null) inventory.fluids(definition.fluids().id(), fluids); redstone = definition.redstone() ? new RedstoneControl(this, List.of(features)) : null; + visuals = new MachineVisualState( + !definition.hasConditionalAppearance() + ? new VisualVariables.Builder().build() + : BuiltinVisualVariables.forMachine( + definition, features.processor(definition.processor()).orElseThrow(), items, + fluids, energy), + definition.visualConditions()); + } + + @Override + public void onLoad() { + super.onLoad(); + if (legacyGhostRecipe != null && level != null && !level.isClientSide) { + migrateGhostRecipe(legacyGhostRecipe, level.registryAccess()); + legacyGhostRecipe = null; + setChanged(); + } + if (level != null && !level.isClientSide) + visuals.start(() -> level.scheduleTick(worldPosition, getBlockState().getBlock(), 1)); + } + + @Override + public void setRemoved() { + visuals.stop(); + super.setRemoved(); + } + + void flushVisuals() { + visuals.flush(this); + } + + @Override + protected void saveClientData(CompoundTag tag, + net.minecraft.core.HolderLookup.Provider registries) { + tag.put("visuals", visuals.write()); + saveGhosts(tag, registries); + } + + @Override + protected void loadClientData(CompoundTag tag, + net.minecraft.core.HolderLookup.Provider registries) { + visuals.read(tag.getCompound("visuals"), this); + readGhosts(tag, registries); + } + + @Override + public net.minecraft.world.item.ItemStack screenGhost(String inventory, int slot) { + var group = ghosts.get(inventory); + return group == null ? net.minecraft.world.item.ItemStack.EMPTY : group.peek(slot); + } + + @Override + public boolean setScreenGhost(net.minecraft.world.entity.player.Player player, String inventory, + int slot, net.minecraft.world.item.ItemStack sample) { + var group = ghosts.get(inventory); + if (group == null || (level != null && level.isClientSide) || !group.set(slot, sample)) + return false; + setChanged(); + features.markSync(); + return true; + } + + private void saveGhosts(CompoundTag tag, net.minecraft.core.HolderLookup.Provider registries) { + var saved = new CompoundTag(); + ghosts.forEach((id, group) -> saved.put(id, group.save(registries))); + tag.put("mmdlib_ghost_items", saved); + } + + private void readGhosts(CompoundTag tag, net.minecraft.core.HolderLookup.Provider registries) { + var saved = tag.getCompound("mmdlib_ghost_items"); + ghosts.forEach((id, group) -> { + if (saved.contains(id)) + group.load(saved.getCompound(id), registries); + }); + } + + @Override + protected void saveMachineData(CompoundTag tag, + net.minecraft.core.HolderLookup.Provider registries) { + saveGhosts(tag, registries); + } + + @Override + protected void loadMachineData(CompoundTag tag, + net.minecraft.core.HolderLookup.Provider registries) { + legacyGhostRecipe = null; + for (var ghost : definition.ghosts()) { + var sample = ghost.defaultItem() == null + ? net.minecraft.world.item.ItemStack.EMPTY + : new net.minecraft.world.item.ItemStack( + BuiltInRegistries.ITEM.get(ghost.defaultItem())); + for (int i = 0; i < ghost.count(); i++) + ghosts.get(ghost.id()).set(i, sample); + } + readGhosts(tag, registries); + // Migrate the earlier output selector, including an explicitly cleared selection. + var old = tag.getCompound("features").getCompound(definition.processor()); + if (!tag.contains("mmdlib_ghost_items") && old.contains("selection") && ghosts.size() == 1 + && ghosts.values().iterator().next().size() == 1) { + var id = ResourceLocation.tryParse(old.getCompound("selection").getString("recipe")); + ghosts.values().iterator().next().set(0, net.minecraft.world.item.ItemStack.EMPTY); + if (id != null) { + if (level == null) + legacyGhostRecipe = id; + else + migrateGhostRecipe(id, registries); + } + } + } + + private void migrateGhostRecipe(ResourceLocation id, + net.minecraft.core.HolderLookup.Provider registries) { + var recipe = level.getRecipeManager().byKey(id).orElse(null); + if (recipe != null + && recipe.value() instanceof zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry entry) + ghosts.values().iterator().next().set(0, entry.getResultItem(registries)); + } + + @Override + public net.neoforged.neoforge.client.model.data.ModelData getModelData() { + return visuals.modelData(); } @Override diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java index 04e0927c..9bbd9709 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java @@ -1,7 +1,13 @@ package zone.moddev.mc.lib.machine.neoforge; import java.lang.reflect.Constructor; -import java.util.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; import com.google.gson.JsonElement; @@ -14,7 +20,8 @@ import net.minecraft.resources.RegistryOps; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.packs.resources.*; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; import net.minecraft.util.profiling.ProfilerFiller; import net.minecraft.world.item.Item; import net.minecraft.world.item.crafting.RecipeHolder; @@ -23,244 +30,266 @@ import zone.moddev.mc.lib.machine.definition.MachineDefinition; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; -import zone.moddev.mc.lib.machine.recipe.*; -import zone.moddev.mc.lib.machine.recipe.generation.*; +import zone.moddev.mc.lib.machine.recipe.MachineProduct; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeEntry; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; +import zone.moddev.mc.lib.machine.recipe.generation.GeneratorProperties; +import zone.moddev.mc.lib.machine.recipe.generation.MachineRecipeGenerator; +import zone.moddev.mc.lib.machine.recipe.generation.RecipeGenerationContext; +import zone.moddev.mc.lib.machine.recipe.generation.ReloadTags; /** * Appended after vanilla tag/recipe listeners: publishes only into the candidate reload manager. */ public final class GeneratedMachineRecipes { - private record Provider( - MachineDefinition.Generator definition, - Constructor constructor) {} + private record Provider( + MachineDefinition.Generator definition, + Constructor constructor) { + } - private static final Map providers = new TreeMap<>(); + private static final Map providers = new TreeMap<>(); - private GeneratedMachineRecipes() {} + private GeneratedMachineRecipes() { + } - static void configure(Map declarations) { - providers.clear(); - declarations.forEach( - (id, type) -> { - var generator = type.generator(); - if (generator == null) return; - try { - var clazz = - Class.forName(generator.className(), false, generator.loader()) - .asSubclass(MachineRecipeGenerator.class); - if (!java.lang.reflect.Modifier.isPublic(clazz.getModifiers()) - || java.lang.reflect.Modifier.isAbstract(clazz.getModifiers())) - throw new IllegalArgumentException( - "Generator class must be public and concrete: " + generator.className()); - providers.put(id, new Provider(generator, clazz.getConstructor())); - } catch (ReflectiveOperationException | ClassCastException ex) { - throw new IllegalArgumentException( - generator.source() - + ":" - + generator.line() - + ": invalid recipe generator " - + generator.className(), - ex); - } - }); - } + static void configure(Map declarations) { + providers.clear(); + declarations.forEach( + (id, type) -> { + var generator = type.generator(); + if (generator == null) + return; + try { + var clazz = Class.forName(generator.className(), false, generator.loader()) + .asSubclass(MachineRecipeGenerator.class); + if (!java.lang.reflect.Modifier.isPublic(clazz.getModifiers()) + || java.lang.reflect.Modifier.isAbstract(clazz.getModifiers())) + throw new IllegalArgumentException( + "Generator class must be public and concrete: " + + generator.className()); + providers.put(id, new Provider(generator, clazz.getConstructor())); + } catch (ReflectiveOperationException | ClassCastException ex) { + throw new IllegalArgumentException( + generator.source() + + ":" + + generator.line() + + ": invalid recipe generator " + + generator.className(), + ex); + } + }); + } - public static void register(AddReloadListenerEvent event) { - event.addListener( - new SimplePreparableReloadListener() { - @Override - protected Boolean prepare(ResourceManager manager, ProfilerFiller profiler) { - return true; - } + public static void register(AddReloadListenerEvent event) { + event.addListener( + new SimplePreparableReloadListener() { + @Override + protected Boolean prepare(ResourceManager manager, ProfilerFiller profiler) { + return true; + } - @Override - protected void apply(Boolean prepared, ResourceManager manager, ProfilerFiller profiler) { - var recipes = event.getServerResources().getRecipeManager(); - var tags = new TreeMap>>(); - event - .getConditionContext() - .getAllTags(Registries.ITEM) - .forEach((id, holders) -> tags.put(id, List.copyOf(holders))); - var blockTags = - new TreeMap< - ResourceLocation, List>>(); - event - .getConditionContext() - .getAllTags(Registries.BLOCK) - .forEach((id, holders) -> blockTags.put(id, List.copyOf(holders))); - recipes.replaceRecipes( - generate( - List.copyOf(recipes.getRecipes()), - event.getServerResources().getRegistryLookup(), - new ReloadTags(tags, blockTags))); - } - }); - } + @Override + protected void apply(Boolean prepared, ResourceManager manager, + ProfilerFiller profiler) { + var recipes = event.getServerResources().getRecipeManager(); + var tags = new TreeMap>>(); + event + .getConditionContext() + .getAllTags(Registries.ITEM) + .forEach((id, holders) -> tags.put(id, List.copyOf(holders))); + var blockTags = new TreeMap>>(); + event + .getConditionContext() + .getAllTags(Registries.BLOCK) + .forEach((id, holders) -> blockTags.put(id, List.copyOf(holders))); + recipes.replaceRecipes( + generate( + List.copyOf(recipes.getRecipes()), + event.getServerResources().getRegistryLookup(), + new ReloadTags(tags, blockTags))); + } + }); + } - /** - * Builds a fresh candidate; exposed to runtime tests without altering the active recipe manager. - */ - public static List> generate( - List> authored, HolderLookup.Provider registries, ReloadTags tags) { - var expanded = expand(authored); - var generated = new TreeMap>(); - var original = new TreeMap>(); - expanded.forEach(holder -> original.put(holder.id(), holder)); - providers.forEach( - (id, provider) -> { - var spec = provider.definition(); - var properties = new GeneratorProperties(spec.properties(), spec.source(), spec.line()); - var type = MachineContent.recipeType(id); - try { - provider - .constructor() - .newInstance() - .generate( - new RecipeGenerationContext(type, registries, tags, expanded, properties), - (recipeId, recipe) -> { - if (!recipeId.getNamespace().equals(id.getNamespace())) - throw properties.error( - "Generated recipe must use namespace " + id.getNamespace()); - // Round-trip validates every amount/product and takes an independent - // snapshot. - var ops = generationOps(registries); - var encoded = - MachineRecipe.CODEC.codec().encodeStart(ops, recipe).getOrThrow(); - var snapshot = MachineRecipe.CODEC.codec().parse(ops, encoded).getOrThrow(); - validateRecipe( - new RecipeHolder<>(recipeId, new MachineRecipeEntry(type, snapshot))); - if (generated.putIfAbsent( - recipeId, - new RecipeHolder<>(recipeId, new MachineRecipeEntry(type, snapshot))) - != null) throw properties.error("Duplicate generated recipe " + recipeId); - }); - } catch (ReflectiveOperationException | RuntimeException ex) { - throw new IllegalArgumentException( - spec.source() - + ":" - + spec.line() - + ": generator " - + spec.className() - + " failed: " - + ex.getMessage(), - ex); - } - }); - generated.putAll(original); // Authored files replace generated entries with the same ID. - generated.values().forEach(GeneratedMachineRecipes::validateRecipe); - return List.copyOf(generated.values()); - } + /** + * Builds a fresh candidate; exposed to runtime tests without altering the active recipe + * manager. + */ + public static List> generate( + List> authored, HolderLookup.Provider registries, ReloadTags tags) { + var expanded = expand(authored); + var generated = new TreeMap>(); + var original = new TreeMap>(); + expanded.forEach(holder -> original.put(holder.id(), holder)); + providers.forEach( + (id, provider) -> { + var spec = provider.definition(); + var properties = new GeneratorProperties(spec.properties(), spec.source(), + spec.line()); + var type = MachineContent.recipeType(id); + try { + provider + .constructor() + .newInstance() + .generate( + new RecipeGenerationContext(type, registries, tags, expanded, + properties), + (recipeId, recipe) -> { + if (!recipeId.getNamespace().equals(id.getNamespace())) + throw properties.error( + "Generated recipe must use namespace " + id.getNamespace()); + // Round-trip validates every amount/product and takes an + // independent + // snapshot. + var ops = generationOps(registries); + var encoded = MachineRecipe.CODEC.codec().encodeStart(ops, recipe) + .getOrThrow(); + var snapshot = MachineRecipe.CODEC.codec().parse(ops, encoded) + .getOrThrow(); + validateRecipe( + new RecipeHolder<>(recipeId, + new MachineRecipeEntry(type, snapshot))); + if (generated.putIfAbsent( + recipeId, + new RecipeHolder<>(recipeId, + new MachineRecipeEntry(type, snapshot))) != null) + throw properties + .error("Duplicate generated recipe " + recipeId); + }); + } catch (ReflectiveOperationException | RuntimeException ex) { + throw new IllegalArgumentException( + spec.source() + + ":" + + spec.line() + + ": generator " + + spec.className() + + " failed: " + + ex.getMessage(), + ex); + } + }); + generated.putAll(original); // Authored files replace generated entries with the same ID. + generated.values().forEach(GeneratedMachineRecipes::validateRecipe); + return List.copyOf(generated.values()); + } - /** Keep reload's tag getter, but use its underlying owner when encoding registry holders. */ - private static RegistryOps generationOps(HolderLookup.Provider registries) { - return RegistryOps.create( - JsonOps.INSTANCE, - new RegistryOps.RegistryInfoLookup() { - @Override - public Optional> lookup( - ResourceKey> key) { - return registries - .lookup(key) - .map( - lookup -> { - var owner = lookup; - while (owner instanceof HolderLookup.RegistryLookup.Delegate delegate) - owner = delegate.parent(); - return new RegistryOps.RegistryInfo<>( - owner, lookup, lookup.registryLifecycle()); - }); - } - }); - } + /** Keep reload's tag getter, but use its underlying owner when encoding registry holders. */ + private static RegistryOps generationOps(HolderLookup.Provider registries) { + return RegistryOps.create( + JsonOps.INSTANCE, + new RegistryOps.RegistryInfoLookup() { + @Override + public Optional> lookup( + ResourceKey> key) { + return registries + .lookup(key) + .map( + lookup -> { + var owner = lookup; + while (owner instanceof HolderLookup.RegistryLookup.Delegate delegate) + owner = delegate.parent(); + return new RegistryOps.RegistryInfo<>( + owner, lookup, lookup.registryLifecycle()); + }); + } + }); + } - private static List> expand(List> authored) { - var result = new TreeMap>(); - for (var holder : authored) { - if (holder.value() instanceof MachineRecipeBundle bundle) { - bundle - .recipes() - .forEach( - (key, conditional) -> - conditional.ifPresent( - value -> { - var id = - ResourceLocation.fromNamespaceAndPath( - holder.id().getNamespace(), holder.id().getPath() + "/" + key); - if (!(value.carrier() instanceof MachineRecipeEntry)) - throw new IllegalArgumentException( - "Recipe " - + id - + " in " - + holder.id() - + ": bundle entries must be machine recipes"); - add(result, new RecipeHolder<>(id, value.carrier())); - })); - } else add(result, holder); + private static List> expand(List> authored) { + var result = new TreeMap>(); + for (var holder : authored) { + if (holder.value() instanceof MachineRecipeBundle bundle) { + bundle + .recipes() + .forEach( + (key, conditional) -> conditional.ifPresent( + value -> { + var id = ResourceLocation.fromNamespaceAndPath( + holder.id().getNamespace(), holder.id().getPath() + "/" + key); + if (!(value.carrier() instanceof MachineRecipeEntry)) + throw new IllegalArgumentException( + "Recipe " + + id + + " in " + + holder.id() + + ": bundle entries must be machine recipes"); + add(result, new RecipeHolder<>(id, value.carrier())); + })); + } else + add(result, holder); + } + return List.copyOf(result.values()); } - return List.copyOf(result.values()); - } - private static void add(Map> result, RecipeHolder holder) { - if (result.putIfAbsent(holder.id(), holder) != null) - throw new IllegalArgumentException( - "Duplicate authored recipe " + holder.id() + " (bundle entry or standalone file)"); - } + private static void add(Map> result, RecipeHolder holder) { + if (result.putIfAbsent(holder.id(), holder) != null) + throw new IllegalArgumentException( + "Duplicate authored recipe " + holder.id() + " (bundle entry or standalone file)"); + } - private static void validateRecipe(RecipeHolder holder) { - if (!(holder.value() instanceof MachineRecipeEntry entry)) return; - try { - validateGroups(entry.type(), entry.recipe()); - } catch (IllegalArgumentException error) { - throw new IllegalArgumentException( - "Recipe " + holder.id() + ": " + error.getMessage(), error); + private static void validateRecipe(RecipeHolder holder) { + if (!(holder.value() instanceof MachineRecipeEntry entry)) + return; + try { + validateGroups(entry.type(), entry.recipe()); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException( + "Recipe " + holder.id() + ": " + error.getMessage(), error); + } } - } - private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) { - for (var machine : MachineContent.definitions().values()) - if (machine.recipeType().id().equals(type.id())) { - var items = new HashSet(); - machine.groups().forEach(g -> items.add(g.id())); - var fluids = new HashSet(); - if (machine.fluids() != null) machine.fluids().groups().forEach(g -> fluids.add(g.id())); - String energy = machine.power() == null ? null : machine.power().id(); - recipe - .inputs() - .forEach( - (key, parts) -> { - if (!items.contains(key) && !fluids.contains(key) && !key.equals(energy)) - throw new IllegalArgumentException( - machine.id() + ": unknown recipe input group " + key); - parts.forEach( - part -> { - boolean valid = - part instanceof MachineIngredient.ItemPart - ? items.contains(key) - : part instanceof MachineIngredient.FluidPart - ? fluids.contains(key) - : part instanceof MachineIngredient.EnergyPart - && key.equals(energy); - if (!valid) - throw new IllegalArgumentException( - machine.id() + ": incompatible recipe input group " + key); - }); - }); - recipe - .outputs() - .forEach( - (key, parts) -> { - if (!items.contains(key) && !fluids.contains(key)) - throw new IllegalArgumentException( - machine.id() + ": unknown recipe output group " + key); - parts.forEach( - part -> { - if (!(part instanceof MachineProduct.ItemProduct && items.contains(key)) - && !(part instanceof MachineProduct.FluidProduct - && fluids.contains(key))) - throw new IllegalArgumentException( - machine.id() + ": incompatible recipe output group " + key); - }); - }); - } - } + private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) { + for (var machine : MachineContent.definitions().values()) + if (machine.recipeType().id().equals(type.id())) { + var items = new HashSet(); + machine.groups().forEach(g -> items.add(g.id())); + var ghostItems = new HashSet(); + machine.ghosts().forEach(g -> ghostItems.add(g.id())); + var fluids = new HashSet(); + if (machine.fluids() != null) + machine.fluids().groups().forEach(g -> fluids.add(g.id())); + String energy = machine.power() == null ? null : machine.power().id(); + recipe + .inputs() + .forEach( + (key, parts) -> { + if (!items.contains(key) && !ghostItems.contains(key) + && !fluids.contains(key) && !key.equals(energy)) + throw new IllegalArgumentException( + machine.id() + ": unknown recipe input group " + key); + parts.forEach( + part -> { + boolean valid = part instanceof MachineIngredient.ItemPart + ? items.contains(key) || ghostItems.contains(key) + : part instanceof MachineIngredient.FluidPart + ? fluids.contains(key) + : part instanceof MachineIngredient.EnergyPart + && key.equals(energy); + if (!valid) + throw new IllegalArgumentException( + machine.id() + ": incompatible recipe input group " + + key); + }); + }); + recipe + .outputs() + .forEach( + (key, parts) -> { + if (!items.contains(key) && !fluids.contains(key)) + throw new IllegalArgumentException( + machine.id() + ": unknown recipe output group " + key); + parts.forEach( + part -> { + if (!(part instanceof MachineProduct.ItemProduct + && items.contains(key)) + && !(part instanceof MachineProduct.FluidProduct + && fluids.contains(key))) + throw new IllegalArgumentException( + machine.id() + ": incompatible recipe output group " + + key); + }); + }); + } + } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInventoryLifecycle.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInventoryLifecycle.java index 925038c9..12422b16 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInventoryLifecycle.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineInventoryLifecycle.java @@ -142,13 +142,19 @@ else if (tag.contains(key)) } public void tick(boolean processingAllowed, boolean paused, PausePolicy policy) { + tick(processingAllowed, paused, policy, true); + } + + /** Structure availability gates transfers separately from redstone's pause policy. */ + public void tick(boolean processingAllowed, boolean paused, PausePolicy policy, + boolean structureReady) { var level = owner.getLevel(); if (level == null || level.isClientSide) return; features.tick(level, processingAllowed && policy.processingEnabled(paused)); // Refunds are already owed; a pause must not prevent their return. automation.settleRefund(power); - if (policy.transfersEnabled(paused)) + if (structureReady && policy.transfersEnabled(paused)) AutomationTransfers.tick(automation, power); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java index d0ca5571..f21acf27 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java @@ -1,189 +1,268 @@ package zone.moddev.mc.lib.machine.neoforge; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.InputStream; + import java.nio.charset.StandardCharsets; -import java.util.*; -import com.google.gson.*; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import net.minecraft.SharedConstants; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.packs.*; -import net.minecraft.server.packs.repository.*; +import net.minecraft.server.packs.AbstractPackResources; +import net.minecraft.server.packs.PackLocationInfo; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackSelectionConfig; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackSource; import net.minecraft.server.packs.resources.IoSupplier; import net.neoforged.neoforge.event.AddPackFindersEvent; /** Low-priority generated assets/data. Authored resource packs and datapacks can override them. */ public final class MachineResourcePack extends AbstractPackResources { - private final PackType type; - private final Map files; - - private MachineResourcePack(PackLocationInfo location, PackType type) { - super(location); - this.type = type; - files = build(type); - } - - public static void register(AddPackFindersEvent event) { - var type = event.getPackType(); - event.addRepositorySource( - output -> { - var location = - new PackLocationInfo( - "mmdlib_machine_defaults", - Component.literal("MMDLib machine defaults"), - PackSource.BUILT_IN, - Optional.empty()); - var supplier = - new Pack.ResourcesSupplier() { - public PackResources openPrimary(PackLocationInfo info) { - return new MachineResourcePack(info, type); - } + private final PackType type; + private final Map files; - public PackResources openFull(PackLocationInfo info, Pack.Metadata metadata) { - return new MachineResourcePack(info, type); + private MachineResourcePack(PackLocationInfo location, PackType type) { + super(location); + this.type = type; + files = build(type); + } + + public static void register(AddPackFindersEvent event) { + var type = event.getPackType(); + event.addRepositorySource( + output -> { + var location = new PackLocationInfo( + "mmdlib_machine_defaults", + Component.literal("MMDLib machine defaults"), + PackSource.BUILT_IN, + Optional.empty()); + var supplier = new Pack.ResourcesSupplier() { + public PackResources openPrimary(PackLocationInfo info) { + return new MachineResourcePack(info, type); + } + + public PackResources openFull(PackLocationInfo info, Pack.Metadata metadata) { + return new MachineResourcePack(info, type); + } + }; + var pack = Pack.readMetaAndCreate( + location, + supplier, + type, + new PackSelectionConfig(true, Pack.Position.BOTTOM, true)); + if (pack == null) + throw new IllegalStateException("Could not create machine resource pack"); + output.accept(pack); + }); + } + + public static Map build(PackType type) { + return build(type, MachineContent.definitions()); + } + + /** Generate assets from resolved definitions, also usable by tooling before registration. */ + public static Map build(PackType type, + Map definitions) { + var result = new TreeMap(); + var languages = new TreeMap(); + var tags = new TreeMap(); + for (var machine : definitions.values()) { + var id = machine.id(); + String model = id.getNamespace() + ":block/" + id.getPath(); + if (type == PackType.CLIENT_RESOURCES) { + var block = new JsonObject(); + block.addProperty("parent", machine.baseModel() == null + ? "minecraft:block/orientable" + : machine.baseModel().toString()); + if (machine.baseModel() == null) { + var textures = new JsonObject(); + machine.textures().forEach(textures::addProperty); + block.add("textures", textures); } - }; - var pack = - Pack.readMetaAndCreate( - location, - supplier, - type, - new PackSelectionConfig(true, Pack.Position.BOTTOM, true)); - if (pack == null) - throw new IllegalStateException("Could not create machine resource pack"); - output.accept(pack); - }); - } - - public static Map build(PackType type) { - var result = new TreeMap(); - var languages = new TreeMap(); - var tags = new TreeMap(); - for (var machine : MachineContent.definitions().values()) { - var id = machine.id(); - String model = id.getNamespace() + ":block/" + id.getPath(); - if (type == PackType.CLIENT_RESOURCES) { - var block = new JsonObject(); - block.addProperty("parent", "minecraft:block/orientable"); - var textures = new JsonObject(); - machine.textures().forEach(textures::addProperty); - block.add("textures", textures); - put(result, id, "models/block/", block); - var item = new JsonObject(); - item.addProperty("parent", model); - put(result, id, "models/item/", item); - var variants = new JsonObject(); - String[] directions = {"north", "east", "south", "west"}; - for (int i = 0; i < 4; i++) { - var variant = new JsonObject(); - variant.addProperty("model", model); - variant.addProperty("y", i * 90); - variants.add("facing=" + directions[i], variant); + if (machine.hasConditionalAppearance()) { + put(result, id.withSuffix("_base"), "models/block/", block); + block = new JsonObject(); + block.addProperty("loader", "mmdlib:conditional_machine"); + var base = new JsonObject(); + base.addProperty("parent", model + "_base"); + block.add("base", base); + var variables = new JsonObject(); + machine.visualDefaults().forEach((name, value) -> { + if (value instanceof Boolean bool) + variables.addProperty(name, bool); + else if (value instanceof Number number) + variables.addProperty(name, number); + else + variables.addProperty(name, (String) value); + }); + block.add("variables", variables); + var parts = new JsonArray(); + for (var part : machine.modelParts()) { + var json = new JsonObject(); + json.addProperty("id", part.id()); + json.addProperty("when", part.when()); + var child = new JsonObject(); + child.addProperty("parent", part.model().toString()); + json.add("model", child); + parts.add(json); + } + block.add("parts", parts); + var particle = new JsonObject(); + if (machine.baseModel() == null) + particle.addProperty("particle", machine.textures().get("side")); + var faceTextures = new JsonArray(); + for (int i = 0; i < machine.faceTextures().size(); i++) { + var rule = machine.faceTextures().get(i); + String textureKey = "face_texture_" + i; + particle.addProperty(textureKey, rule.texture().toString()); + var json = new JsonObject(); + json.addProperty("face", rule.face().getSerializedName()); + json.addProperty("when", rule.when()); + json.addProperty("texture", textureKey); + if (rule.emissiveMask() != null) { + String maskKey = "face_mask_" + i; + particle.addProperty(maskKey, rule.emissiveMask().toString()); + json.addProperty("emissive_mask", maskKey); + } + faceTextures.add(json); + } + block.add("face_textures", faceTextures); + block.add("textures", particle); + block.addProperty("parent", machine.baseModel() == null + ? "minecraft:block/block" + : model + "_base"); + } + put(result, id, "models/block/", block); + var item = new JsonObject(); + item.addProperty("parent", model); + put(result, id, "models/item/", item); + var variants = new JsonObject(); + String[] directions = {"north", "east", "south", "west"}; + for (int i = 0; i < 4; i++) { + var variant = new JsonObject(); + variant.addProperty("model", model); + variant.addProperty("y", i * 90); + variants.add("facing=" + directions[i], variant); + } + var state = new JsonObject(); + state.add("variants", variants); + put(result, id, "blockstates/", state); + var lang = languages.computeIfAbsent(id.getNamespace(), key -> new JsonObject()); + lang.addProperty( + "block." + id.getNamespace() + "." + id.getPath().replace('/', '.'), + machine.name()); + lang.addProperty(id + ".info", machine.name()); + var recipeId = machine.recipeType().id(); + if (machine.recipeType().declare()) + languages + .computeIfAbsent(recipeId.getNamespace(), key -> new JsonObject()) + .addProperty( + "jei." + recipeId.getNamespace() + "." + recipeId.getPath(), + machine.recipeType().name()); + } else { + var table = JsonParser.parseString( + "{\"type\":\"minecraft:block\",\"pools\":[{\"rolls\":1,\"entries\":[{\"type\":\"minecraft:item\",\"name\":\"" + + id + + "\"}],\"conditions\":[{\"condition\":\"minecraft:survives_explosion\"}]}]}"); + put(result, id, "loot_table/blocks/", table); + tags.computeIfAbsent(machine.mineable(), key -> new JsonArray()).add(id.toString()); + } } - var state = new JsonObject(); - state.add("variants", variants); - put(result, id, "blockstates/", state); - var lang = languages.computeIfAbsent(id.getNamespace(), key -> new JsonObject()); - lang.addProperty( - "block." + id.getNamespace() + "." + id.getPath().replace('/', '.'), machine.name()); - lang.addProperty(id + ".info", machine.name()); - var recipeId = machine.recipeType().id(); - if (machine.recipeType().declare()) - languages - .computeIfAbsent(recipeId.getNamespace(), key -> new JsonObject()) - .addProperty( - "jei." + recipeId.getNamespace() + "." + recipeId.getPath(), - machine.recipeType().name()); - } else { - var table = - JsonParser.parseString( - "{\"type\":\"minecraft:block\",\"pools\":[{\"rolls\":1,\"entries\":[{\"type\":\"minecraft:item\",\"name\":\"" - + id - + "\"}],\"conditions\":[{\"condition\":\"minecraft:survives_explosion\"}]}]}"); - put(result, id, "loot_table/blocks/", table); - tags.computeIfAbsent(machine.mineable(), key -> new JsonArray()).add(id.toString()); - } + if (type == PackType.CLIENT_RESOURCES) { + languages + .computeIfAbsent("mmdlib", key -> new JsonObject()) + .addProperty("item.mmdlib.machine_recovery_capsule", "Machine Recovery Capsule"); + languages.forEach( + (namespace, lang) -> putRaw( + result, + ResourceLocation.fromNamespaceAndPath(namespace, "lang/en_us.json"), + lang)); + put( + result, + MachineContent.RECOVERY, + "models/item/", + JsonParser.parseString( + "{\"parent\":\"minecraft:item/generated\",\"textures\":{\"layer0\":\"minecraft:item/bundle\"}}")); + } else + tags.forEach( + (id, values) -> { + var tag = new JsonObject(); + tag.addProperty("replace", false); + tag.add("values", values); + put(result, id, "tags/block/", tag); + }); + return Collections.unmodifiableMap(result); + } + + private static void put( + Map files, ResourceLocation id, String prefix, JsonElement json) { + putRaw( + files, + ResourceLocation.fromNamespaceAndPath(id.getNamespace(), + prefix + id.getPath() + ".json"), + json); + } + + private static void putRaw( + Map files, ResourceLocation id, JsonElement json) { + if (files.putIfAbsent(id, json.toString().getBytes(StandardCharsets.UTF_8)) != null) + throw new IllegalArgumentException("Duplicate generated resource " + id); } - if (type == PackType.CLIENT_RESOURCES) { - languages - .computeIfAbsent("mmdlib", key -> new JsonObject()) - .addProperty("item.mmdlib.machine_recovery_capsule", "Machine Recovery Capsule"); - languages.forEach( - (namespace, lang) -> - putRaw( - result, - ResourceLocation.fromNamespaceAndPath(namespace, "lang/en_us.json"), - lang)); - put( - result, - MachineContent.RECOVERY, - "models/item/", - JsonParser.parseString( - "{\"parent\":\"minecraft:item/generated\",\"textures\":{\"layer0\":\"minecraft:item/bundle\"}}")); - } else - tags.forEach( - (id, values) -> { - var tag = new JsonObject(); - tag.addProperty("replace", false); - tag.add("values", values); - put(result, id, "tags/block/", tag); - }); - return Collections.unmodifiableMap(result); - } - - private static void put( - Map files, ResourceLocation id, String prefix, JsonElement json) { - putRaw( - files, - ResourceLocation.fromNamespaceAndPath(id.getNamespace(), prefix + id.getPath() + ".json"), - json); - } - - private static void putRaw( - Map files, ResourceLocation id, JsonElement json) { - if (files.putIfAbsent(id, json.toString().getBytes(StandardCharsets.UTF_8)) != null) - throw new IllegalArgumentException("Duplicate generated resource " + id); - } - - @Override - public IoSupplier getRootResource(String... path) { - if (!String.join("/", path).equals("pack.mcmeta")) return null; - byte[] metadata = - ("{\"pack\":{\"pack_format\":" - + SharedConstants.getCurrentVersion().getPackVersion(type) - + ",\"description\":\"MMDLib generated machine defaults\"}}") + + @Override + public IoSupplier getRootResource(String... path) { + if (!String.join("/", path).equals("pack.mcmeta")) + return null; + byte[] metadata = ("{\"pack\":{\"pack_format\":" + + SharedConstants.getCurrentVersion().getPackVersion(type) + + ",\"description\":\"MMDLib generated machine defaults\"}}") .getBytes(StandardCharsets.UTF_8); - return () -> new ByteArrayInputStream(metadata); - } - - @Override - public IoSupplier getResource(PackType requested, ResourceLocation id) { - var data = requested == type ? files.get(id) : null; - return data == null ? null : () -> new ByteArrayInputStream(data); - } - - @Override - public void listResources( - PackType requested, String namespace, String path, PackResources.ResourceOutput output) { - if (requested != type) return; - files.forEach( - (id, data) -> { - if (id.getNamespace().equals(namespace) && id.getPath().startsWith(path + "/")) - output.accept(id, () -> new ByteArrayInputStream(data)); - }); - } - - @Override - public Set getNamespaces(PackType requested) { - if (requested != type) return Set.of(); - var namespaces = new HashSet(); - files.keySet().forEach(id -> namespaces.add(id.getNamespace())); - return Set.copyOf(namespaces); - } - - @Override - public void close() {} + return () -> new ByteArrayInputStream(metadata); + } + + @Override + public IoSupplier getResource(PackType requested, ResourceLocation id) { + var data = requested == type ? files.get(id) : null; + return data == null ? null : () -> new ByteArrayInputStream(data); + } + + @Override + public void listResources( + PackType requested, String namespace, String path, PackResources.ResourceOutput output) { + if (requested != type) + return; + files.forEach( + (id, data) -> { + if (id.getNamespace().equals(namespace) && id.getPath().startsWith(path + "/")) + output.accept(id, () -> new ByteArrayInputStream(data)); + }); + } + + @Override + public Set getNamespaces(PackType requested) { + if (requested != type) + return Set.of(); + var namespaces = new HashSet(); + files.keySet().forEach(id -> namespaces.add(id.getNamespace())); + return Set.copyOf(namespaces); + } + + @Override + public void close() { + } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResources.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResources.java index 31315f26..3912614f 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResources.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResources.java @@ -42,6 +42,7 @@ private record Binding(ResourceGroup group, Object storage) { private final Map bindings = new LinkedHashMap<>(); private final Map> recipeBindings = new LinkedHashMap<>(); private final Map> inputTypes = new LinkedHashMap<>(); + private final Map recipeOnly = new LinkedHashMap<>(); private MachineInventory recipes; private AutomationInventory automation; @@ -162,6 +163,15 @@ public MachineResources recipeInputs(MachineRecipeType type, String... keys) { return this; } + /** Adds an internal recipe group without exposing physical automation slots. */ + public MachineResources recipeOnlyGroup(String key, SlotGroup group) { + ensureOpen(); + if (key == null || key.isBlank() || recipeBindings.containsKey(key) + || recipeOnly.putIfAbsent(key, Objects.requireNonNull(group)) != null) + throw invalid(key, "duplicate or empty recipe-only group"); + return this; + } + /** Freezes the definitions and installs reload-aware recipe input filters. */ public MachineResources build() { ensureOpen(); @@ -180,6 +190,7 @@ public MachineResources build() { : StorageGroups.energy((EnergyBuffer) storage); builder.group(key, group); }); + recipeOnly.forEach(builder::group); recipes = builder.build(); automation = new AutomationInventory(owner, bindings.values().stream().map(Binding::group).toList()); diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineVisualState.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineVisualState.java new file mode 100644 index 00000000..7e72a1ba --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineVisualState.java @@ -0,0 +1,164 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; + +import net.neoforged.neoforge.client.model.data.ModelData; +import net.neoforged.neoforge.client.model.data.ModelProperty; + +import zone.moddev.mc.lib.machine.presentation.VisualCondition; +import zone.moddev.mc.lib.machine.presentation.VisualVariables; + +/** Tracking-player snapshots for static appearance, independent of open menus and saved storage. */ +public final class MachineVisualState { + public static final ModelProperty> VARIABLES = new ModelProperty<>(); + private final VisualVariables variables; + private final Map defaults; + private final List conditions; + private final Set referenced; + private Map lastSent = Map.of(); + private Map clientSnapshot; + private volatile ModelData modelData; + private final Set dirty = new java.util.HashSet<>(); + private Map serverSnapshot = Map.of(); + private Runnable unsubscribe; + private Runnable schedule; + private boolean scheduled; + + public MachineVisualState(VisualVariables variables, List conditions) { + this.variables = variables; + defaults = variables.defaults(); + this.conditions = conditions.stream().map(text -> VisualCondition.compile(text, defaults)) + .toList(); + referenced = this.conditions.stream().flatMap(condition -> condition.variables().stream()) + .collect(Collectors.toUnmodifiableSet()); + clientSnapshot = defaults; + modelData = ModelData.builder().with(VARIABLES, clientSnapshot).build(); + dirty.addAll(referenced); + unsubscribe = variables.subscribe(referenced, this::invalidate); + } + + /** Bind a one-shot scheduler when the owner loads; no repeating tick callback is needed. */ + public void start(Runnable schedule) { + this.schedule = schedule; + if (unsubscribe == null) + unsubscribe = variables.subscribe(referenced, this::invalidate); + dirty.addAll(referenced); + requestUpdate(); + } + + /** Disconnect component listeners on removal/unload. start() can reconnect a restored owner. */ + public void stop() { + if (unsubscribe != null) + unsubscribe.run(); + unsubscribe = null; + schedule = null; + scheduled = false; + } + + private void invalidate(String name) { + dirty.add(name); + requestUpdate(); + } + + private void requestUpdate() { + if (!referenced.isEmpty() && schedule != null && !scheduled) { + scheduled = true; + schedule.run(); + } + } + + private Map snapshot() { + if (!dirty.isEmpty()) { + var next = new HashMap<>(serverSnapshot); + next.putAll(variables.snapshot(Set.copyOf(dirty))); + dirty.clear(); + serverSnapshot = Map.copyOf(next); + } + return serverSnapshot; + } + + /** Called by the scheduled callback, after component mutations have settled. */ + public void flush(BlockEntity entity) { + if (entity.isRemoved() || entity.getLevel() == null || entity.getLevel().isClientSide) + return; + flush(() -> FeatureSync.send(entity)); + } + + /** Flush a coalesced invalidation through the owner's broadcast transport. */ + public void flush(Runnable broadcast) { + scheduled = false; + var current = snapshot(); + if (!current.equals(lastSent)) { + lastSent = current; + broadcast.run(); + } + } + + /** A full initial snapshot, also used for subsequent updates. Never persisted to disk. */ + public CompoundTag write() { + // Initial chunk data may be requested for just one newly tracking player. Reading it + // must not acknowledge a broadcast and suppress updates to the other tracking players. + var snapshot = snapshot(); + var tag = new CompoundTag(); + snapshot.forEach((name, value) -> { + switch (VisualVariables.Kind.of(value)) { + case BOOLEAN -> tag.putBoolean(name, (Boolean) value); + case NUMBER -> tag.putDouble(name, ((Number) value).doubleValue()); + case TEXT -> tag.putString(name, (String) value); + } + }); + return tag; + } + + public void read(CompoundTag tag, BlockEntity entity) { + if (apply(tag)) { + entity.requestModelDataUpdate(); + var level = entity.getLevel(); + if (level != null && level.isClientSide) + level.sendBlockUpdated(entity.getBlockPos(), entity.getBlockState(), + entity.getBlockState(), Block.UPDATE_CLIENTS); + } + } + + /** Apply a detached client snapshot; return whether static geometry needs refreshing. */ + public boolean apply(CompoundTag tag) { + var values = new HashMap<>(defaults); + for (String name : referenced) { + Object value = switch (VisualVariables.Kind.of(defaults.get(name))) { + case BOOLEAN -> + tag.contains(name, Tag.TAG_BYTE) ? tag.getBoolean(name) : defaults.get(name); + case NUMBER -> + tag.contains(name, Tag.TAG_DOUBLE) && Double.isFinite(tag.getDouble(name)) + ? tag.getDouble(name) + : defaults.get(name); + case TEXT -> + tag.contains(name, Tag.TAG_STRING) && tag.getString(name).length() <= 256 + ? tag.getString(name) + : defaults.get(name); + }; + values.put(name, value); + } + var next = Map.copyOf(values); + if (next.equals(clientSnapshot)) + return false; + boolean changed = conditions.stream() + .anyMatch(condition -> condition.test(clientSnapshot) != condition.test(next)); + clientSnapshot = next; + modelData = ModelData.builder().with(VARIABLES, next).build(); + return changed; + } + + /** Immutable data only; safe to read from chunk rendering threads. */ + public ModelData modelData() { + return modelData; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java new file mode 100644 index 00000000..8a7dc973 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java @@ -0,0 +1,120 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.function.Supplier; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.phys.BlockHitResult; + +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.multiblock.MultiblockController; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; + +/** + * Optional block wiring for controllers and physical parts; shapes and item interactions are + * overridable. + */ +public class MultiblockBlock extends Block implements EntityBlock { + private final Supplier> type; + private final boolean controller; + public MultiblockBlock(Properties properties, Supplier> type) { + this(properties, type, true); + } + public MultiblockBlock(Properties properties, Supplier> type, + boolean controller) { + super(properties); + this.type = type; + this.controller = controller; + registerDefaultState(stateDefinition.any().setValue(BlockStateProperties.HORIZONTAL_FACING, + Direction.NORTH)); + } + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(BlockStateProperties.HORIZONTAL_FACING); + } + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return type.get().create(pos, state); + } + @Override + public BlockState rotate(BlockState state, net.minecraft.world.level.block.Rotation rotation) { + return state.setValue(BlockStateProperties.HORIZONTAL_FACING, + rotation.rotate(state.getValue(BlockStateProperties.HORIZONTAL_FACING))); + } + @Override + public BlockState mirror(BlockState state, net.minecraft.world.level.block.Mirror mirror) { + return rotate(state, + mirror.getRotation(state.getValue(BlockStateProperties.HORIZONTAL_FACING))); + } + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, + BlockEntityType requested) { + if (!controller || level.isClientSide || requested != type.get()) + return null; + return (world, pos, block, entity) -> { + if (entity instanceof BaseMachine machine) + machine.serverTick(); + }; + } + @Override + protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) { + MultiblockMembers.reconcile(level, pos); + } + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (level.isClientSide) + return InteractionResult.SUCCESS; + var instance = MultiblockMembers.resolve(level, pos); + var entity = instance == null ? level.getBlockEntity(pos) : instance.owner(); + return ScreenRuntime.open(player, entity, pos) + ? InteractionResult.CONSUME + : InteractionResult.PASS; + } + @Override + public BlockState playerWillDestroy(Level level, BlockPos pos, BlockState state, + Player player) { + if (!level.isClientSide) { + var instance = MultiblockMembers.resolve(level, pos); + if (instance != null) + instance.dismantle(player.isCreative() + ? MultiblockInstance.Cause.CREATIVE + : MultiblockInstance.Cause.SURVIVAL); + } + return super.playerWillDestroy(level, pos, state, player); + } + @Override + protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, + boolean moved) { + if (!state.is(replacement.getBlock()) && !level.isClientSide + && !MultiblockPlacement.writing(level, pos)) { + var entity = level.getBlockEntity(pos); + var link = MultiblockMembers.INSTANCE.link(level, pos); + var instance = entity instanceof MultiblockController controller + ? controller.multiblock() + : null; + if (instance == null && link != null && level.hasChunkAt(link.controller()) + && level + .getBlockEntity(link.controller()) instanceof MultiblockController controller + && controller.multiblock() != null + && controller.multiblock().contains(pos, link)) + instance = controller.multiblock(); + if (instance != null) + instance.dismantle(MultiblockInstance.Cause.RECOVERY); + } + super.onRemove(state, level, pos, replacement, moved); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockItem.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockItem.java new file mode 100644 index 00000000..13a4b021 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockItem.java @@ -0,0 +1,35 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.function.Supplier; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.item.context.UseOnContext; + +import zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition; + +/** One item places the complete fixed machine. Member blocks should have no independent loot. */ +public class MultiblockItem extends Item { + private final Supplier definition; + public MultiblockItem(Properties properties, Supplier definition) { + super(properties); + this.definition = definition; + } + @Override + public InteractionResult useOn(UseOnContext context) { + if (context.getLevel().isClientSide) + return InteractionResult.SUCCESS; + var result = MultiblockPlacement.place(new BlockPlaceContext(context), definition.get()); + if (!result.success()) { + if (context.getPlayer() != null) + context.getPlayer().displayClientMessage(Component.literal(result.diagnostic()), + true); + return InteractionResult.FAIL; + } + if (context.getPlayer() != null && !context.getPlayer().isCreative()) + context.getItemInHand().shrink(1); + return InteractionResult.CONSUME; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockMembers.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockMembers.java new file mode 100644 index 00000000..942d8d5a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockMembers.java @@ -0,0 +1,91 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; + +import zone.moddev.mc.lib.machine.multiblock.LinkedMember; +import zone.moddev.mc.lib.machine.multiblock.MemberLink; +import zone.moddev.mc.lib.machine.multiblock.MultiblockController; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; +import zone.moddev.mc.lib.machine.multiblock.StructureMembership; + +/** Linked-entity membership adapter; never requests an unloaded chunk. */ +public enum MultiblockMembers implements StructureMembership { + INSTANCE; + + @Override + public MemberLink link(Level level, BlockPos pos) { + if (!level.hasChunkAt(pos)) + return null; + var entity = level.getBlockEntity(pos); + if (entity instanceof MultiblockController controller && controller.multiblock() != null) + return controller.multiblock().controllerLink(); + return entity instanceof LinkedMember member ? member.memberLink() : null; + } + + @Override + public boolean claim(Level level, BlockPos pos, MemberLink link) { + if (!level.hasChunkAt(pos)) + return false; + var previous = link(level, pos); + if (previous != null && !previous.equals(link)) + return false; + var entity = level.getBlockEntity(pos); + if (entity instanceof LinkedMember member) { + member.memberLink(link); + return true; + } + return entity instanceof MultiblockController && link.equals(previous); + } + + @Override + public void release(Level level, BlockPos pos, MemberLink expected) { + if (expected.equals(link(level, pos)) + && level.getBlockEntity(pos) instanceof LinkedMember member) + member.memberLink(null); + } + + public static MultiblockInstance resolve(Level level, BlockPos pos) { + var link = INSTANCE.link(level, pos); + if (link == null || !link.dimension().equals(level.dimension().location()) + || !level.hasChunkAt(link.controller())) + return null; + var entity = level.getBlockEntity(link.controller()); + if (!(entity instanceof MultiblockController controller) || controller.multiblock() == null) + return null; + var instance = controller.multiblock(); + return instance.owns(pos, link) ? instance : null; + } + + @Override + public void availabilityChanged(Level level, BlockPos pos) { + if (level.hasChunkAt(pos)) + level.invalidateCapabilities(pos); + } + + /** Reconciles orphaned parts on load; unavailable or incompatible owners are left intact. */ + public static void reconcile(Level level, BlockPos pos) { + if (level.isClientSide || !(level.getBlockEntity(pos) instanceof LinkedMember part)) + return; + var link = part.memberLink(); + if (link == null) + return; + if (!link.dimension().equals(level.dimension().location())) + return; + if (!level.hasChunkAt(link.controller())) { + level.scheduleTick(pos, level.getBlockState(pos).getBlock(), 20); + return; + } + var entity = level.getBlockEntity(link.controller()); + if (entity instanceof MultiblockController controller && controller.multiblock() != null) { + var owner = controller.multiblock(); + if (owner.formation() == null || owner.changing()) + return; + if (owner.owns(pos, link)) + return; + } + // Only the still-linked part can remove itself; no machine item is awarded here. + if (link.equals(INSTANCE.link(level, pos))) + level.removeBlock(pos, false); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPartEntity.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPartEntity.java new file mode 100644 index 00000000..720f17d4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPartEntity.java @@ -0,0 +1,67 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import zone.moddev.mc.lib.machine.multiblock.LinkedMember; +import zone.moddev.mc.lib.machine.multiblock.MemberLink; + +/** Non-ticking physical part; the consuming mod registers its type and blocks. */ +public class MultiblockPartEntity extends BlockEntity implements LinkedMember { + private MemberLink link; + public MultiblockPartEntity(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + @Override + public MemberLink memberLink() { + return link; + } + @Override + public void memberLink(MemberLink value) { + link = value; + setChanged(); + if (level != null) { + level.invalidateCapabilities(worldPosition); + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2); + } + } + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + if (link != null) + tag.put("member", link.save()); + } + @Override + protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.loadAdditional(tag, registries); + try { + link = tag.contains("member") ? MemberLink.load(tag.getCompound("member")) : null; + } catch (RuntimeException malformed) { + link = null; + } + } + @Override + public void onLoad() { + super.onLoad(); + if (level != null && !level.isClientSide) + level.scheduleTick(worldPosition, getBlockState().getBlock(), 1); + } + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + var tag = new CompoundTag(); + if (link != null) + tag.put("member", link.save()); + return tag; + } + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPlacement.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPlacement.java new file mode 100644 index 00000000..4302cc8a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPlacement.java @@ -0,0 +1,166 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +import net.neoforged.neoforge.common.util.BlockSnapshot; +import net.neoforged.neoforge.event.EventHooks; + +import zone.moddev.mc.lib.machine.multiblock.LinkedMember; +import zone.moddev.mc.lib.machine.multiblock.MultiblockController; +import zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; +import zone.moddev.mc.lib.machine.multiblock.StructureResolver; +import zone.moddev.mc.lib.machine.multiblock.StructureTransform; + +/** Server placement transaction. It does not consume items or own gameplay resources. */ +public final class MultiblockPlacement { + private record Attempt(net.minecraft.world.level.Level level, + java.util.Set positions) { + } + private static final ThreadLocal WRITING = new ThreadLocal<>(); + private MultiblockPlacement() { + } + public static boolean writing() { + return WRITING.get() != null; + } + public static boolean writing(net.minecraft.world.level.Level level, BlockPos pos) { + var attempt = WRITING.get(); + return attempt != null && attempt.level() == level && attempt.positions().contains(pos); + } + public record Result(boolean success, String diagnostic) { + static Result refused(String reason) { + return new Result(false, reason); + } + } + + public static Result place(BlockPlaceContext context, MultiblockDefinition definition) { + var level = context.getLevel(); + var player = context.getPlayer(); + if (level.isClientSide || writing() || player == null) + return Result.refused("server_player_required"); + var transform = new StructureTransform(context.getClickedPos(), + context.getHorizontalDirection().getOpposite()); + var result = definition.resolver().resolve(new StructureResolver.WorldView() { + @Override + public boolean loaded(BlockPos pos) { + return level.hasChunkAt(pos); + } + @Override + public BlockState state(BlockPos pos) { + if (!loaded(pos)) + throw new IllegalStateException("Resolver read an unloaded chunk"); + return level.getBlockState(pos); + } + }, transform); + if (result.structure() == null) + return Result.refused(result.diagnostic()); + var candidate = result.structure(); + definition.validate(candidate); + var previous = new LinkedHashMap(); + var placed = new LinkedHashMap(); + for (var member : candidate.members()) { + var pos = transform.position(member.offset()); + if (!level.hasChunkAt(pos) || level.isOutsideBuildHeight(pos) + || !level.getWorldBorder().isWithinBounds(pos)) + return Result.refused("unavailable_position:" + member.key()); + var old = level.getBlockState(pos); + var state = member.state().rotate(transform.rotation()); + if (level.getBlockEntity(pos) != null + || MultiblockMembers.INSTANCE.link(level, pos) != null + || !old.canBeReplaced(BlockPlaceContext.at(context, pos, context.getClickedFace())) + || !level.mayInteract(player, pos) + || !player.mayUseItemAt(pos, context.getClickedFace(), context.getItemInHand()) + || !level.isUnobstructed(state, pos, + net.minecraft.world.phys.shapes.CollisionContext.empty())) + return Result.refused("blocked_position:" + member.key()); + previous.put(pos, old); + placed.put(pos, state); + } + for (var requirement : candidate.requirements()) { + var pos = transform.position(requirement.offset()); + if (!level.hasChunkAt(pos) || level.isOutsideBuildHeight(pos) + || !level.getWorldBorder().isWithinBounds(pos) + || !definition.rules().get(requirement.rule()).test(level, pos)) + return Result.refused("requirement:" + requirement.rule()); + } + var ordered = new ArrayList<>(candidate.members()); + ordered.sort(java.util.Comparator.comparing(member -> !member.role().equals("controller"))); + var snapshots = new ArrayList(); + var written = new LinkedHashMap(); + MultiblockInstance instance = null; + boolean success = false; + boolean capture = level.captureBlockSnapshots; + WRITING.set(new Attempt(level, java.util.Set.copyOf(placed.keySet()))); + level.captureBlockSnapshots = false; + try { + for (var member : ordered) { + var pos = transform.position(member.offset()); + if (!level.getBlockState(pos).equals(previous.get(pos))) + return Result.refused("world_changed"); + snapshots.add(BlockSnapshot.create(level.dimension(), level, pos)); + if (!level.setBlock(pos, placed.get(pos), 2)) + return Result.refused("write_failed"); + var entity = level.getBlockEntity(pos); + written.put(pos, entity); + if (member.role().equals("controller")) { + if (!(entity instanceof MultiblockController controller) + || controller.multiblock() == null) + return Result.refused("controller_missing"); + instance = controller.multiblock(); + if (!instance.definition().id().equals(definition.id()) + || instance.definition().version() != definition.version() + || !instance.stage(candidate, transform.facing())) + return Result.refused("controller_definition_mismatch"); + } else { + if (!(entity instanceof LinkedMember part)) + return Result.refused("part_missing"); + part.memberLink(instance.memberLink(member.key())); + } + } + if (EventHooks.onMultiBlockPlace(player, snapshots, context.getClickedFace())) + return Result.refused("placement_denied"); + for (var entry : placed.entrySet()) { + if (level.getBlockEntity(entry.getKey()) != written.get(entry.getKey()) + || !level.getBlockState(entry.getKey()).equals(entry.getValue()) + || !entry.getValue().canSurvive(level, entry.getKey())) + return Result.refused("world_changed"); + } + if (instance == null || !instance.commit()) + return Result.refused("formation_failed"); + success = true; + // Keep all notifications inside our capture boundary; the outer ItemStack use hook + // must not post a second placement event for changes caused by these notifications. + for (var entry : placed.entrySet()) + level.updateNeighborsAt(entry.getKey(), entry.getValue().getBlock()); + } finally { + if (!success) { + if (instance != null && !instance.committed()) + instance.discard(); + var reverse = new ArrayList<>(written.entrySet()); + java.util.Collections.reverse(reverse); + boolean restoring = level.restoringBlockSnapshots; + level.restoringBlockSnapshots = true; + try { + for (var entry : reverse) { + var pos = entry.getKey(); + if (level.getBlockEntity(pos) == entry.getValue() + && level.getBlockState(pos).equals(placed.get(pos))) + level.setBlock(pos, previous.get(pos), 3); + } + } finally { + level.restoringBlockSnapshots = restoring; + } + } + level.captureBlockSnapshots = capture; + WRITING.remove(); + } + return new Result(true, "formed"); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPorts.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPorts.java new file mode 100644 index 00000000..20bb77e2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockPorts.java @@ -0,0 +1,209 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.UUID; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; + +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; +import net.neoforged.neoforge.energy.IEnergyStorage; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.items.IItemHandler; + +import zone.moddev.mc.lib.machine.multiblock.MemberLink; +import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; +import zone.moddev.mc.lib.machine.multiblock.StructurePort; + +/** Formation-bound capability adapters. Retained wrappers become inert after unlink/reformation. */ +public final class MultiblockPorts { + private MultiblockPorts() { + } + public static void register(RegisterCapabilitiesEvent event, BlockEntityType... types) { + for (var type : types) { + event.registerBlockEntity(Capabilities.ItemHandler.BLOCK, type, MultiblockPorts::items); + event.registerBlockEntity(Capabilities.FluidHandler.BLOCK, type, + MultiblockPorts::fluids); + event.registerBlockEntity(Capabilities.EnergyStorage.BLOCK, type, + MultiblockPorts::energy); + } + } + private record Binding(MultiblockInstance instance, MemberLink link, BlockPos position, + Direction side, StructurePort port, UUID formation, long revision) { + MultiblockResources resources() { + var level = instance.owner().getLevel(); + if (level == null || level.isClientSide + || !level.hasChunkAt(instance.owner().getBlockPos()) + || level.getBlockEntity(instance.owner().getBlockPos()) != instance.owner() + || !formation.equals(instance.formation()) || revision != instance.revision() + || !instance.owns(position, link) || !instance.ready()) + return null; + return instance.owner() instanceof MultiblockResources resources ? resources : null; + } + IItemHandler items() { + var r = resources(); + return r == null ? null : r.multiblockItems(port.resource(), side); + } + IFluidHandler fluids() { + var r = resources(); + return r == null ? null : r.multiblockFluids(port.resource(), side); + } + IEnergyStorage energy() { + var r = resources(); + return r == null ? null : r.multiblockEnergy(port.resource(), side); + } + } + private static Binding binding(BlockEntity entity, Direction side, StructurePort.Kind kind) { + var level = entity.getLevel(); + if (level == null || level.isClientSide) + return null; + var instance = MultiblockMembers.resolve(level, entity.getBlockPos()); + if (instance == null || !instance.ready()) + return null; + var link = MultiblockMembers.INSTANCE.link(level, entity.getBlockPos()); + for (var port : instance.definition().ports()) + if (port.kind() == kind && port.member().equals(link.member()) + && port.exposes(side == null ? null : instance.transform().local(side))) + return new Binding(instance, link, entity.getBlockPos(), side, port, + instance.formation(), instance.revision()); + return null; + } + public static IItemHandler items(BlockEntity entity, Direction side) { + var binding = binding(entity, side, StructurePort.Kind.ITEMS); + if (binding == null || binding.items() == null) + return null; + return new IItemHandler() { + private boolean slot(IItemHandler handler, int slot) { + return handler != null && slot >= 0 && slot < handler.getSlots(); + } + @Override + public int getSlots() { + var h = binding.items(); + return h == null ? 0 : h.getSlots(); + } + @Override + public ItemStack getStackInSlot(int slot) { + var h = binding.items(); + return slot(h, slot) ? h.getStackInSlot(slot).copy() : ItemStack.EMPTY; + } + @Override + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + var h = binding.items(); + return binding.port.input() && slot(h, slot) + ? h.insertItem(slot, stack, simulate) + : stack; + } + @Override + public ItemStack extractItem(int slot, int amount, boolean simulate) { + var h = binding.items(); + return binding.port.output() && slot(h, slot) && amount > 0 + ? h.extractItem(slot, amount, simulate) + : ItemStack.EMPTY; + } + @Override + public int getSlotLimit(int slot) { + var h = binding.items(); + return slot(h, slot) ? h.getSlotLimit(slot) : 0; + } + @Override + public boolean isItemValid(int slot, ItemStack stack) { + var h = binding.items(); + return binding.port.input() && slot(h, slot) && h.isItemValid(slot, stack); + } + }; + } + public static IFluidHandler fluids(BlockEntity entity, Direction side) { + var binding = binding(entity, side, StructurePort.Kind.FLUIDS); + if (binding == null || binding.fluids() == null) + return null; + return new IFluidHandler() { + private boolean tank(IFluidHandler h, int tank) { + return h != null && tank >= 0 && tank < h.getTanks(); + } + @Override + public int getTanks() { + var h = binding.fluids(); + return h == null ? 0 : h.getTanks(); + } + @Override + public FluidStack getFluidInTank(int tank) { + var h = binding.fluids(); + return tank(h, tank) ? h.getFluidInTank(tank).copy() : FluidStack.EMPTY; + } + @Override + public int getTankCapacity(int tank) { + var h = binding.fluids(); + return tank(h, tank) ? h.getTankCapacity(tank) : 0; + } + @Override + public boolean isFluidValid(int tank, FluidStack stack) { + var h = binding.fluids(); + return binding.port.input() && tank(h, tank) && h.isFluidValid(tank, stack); + } + @Override + public int fill(FluidStack stack, FluidAction action) { + var h = binding.fluids(); + return h != null && binding.port.input() ? h.fill(stack, action) : 0; + } + @Override + public FluidStack drain(FluidStack stack, FluidAction action) { + var h = binding.fluids(); + return h != null && binding.port.output() + ? h.drain(stack, action) + : FluidStack.EMPTY; + } + @Override + public FluidStack drain(int amount, FluidAction action) { + var h = binding.fluids(); + return h != null && binding.port.output() && amount > 0 + ? h.drain(amount, action) + : FluidStack.EMPTY; + } + }; + } + public static IEnergyStorage energy(BlockEntity entity, Direction side) { + var binding = binding(entity, side, StructurePort.Kind.ENERGY); + if (binding == null || binding.energy() == null) + return null; + return new IEnergyStorage() { + @Override + public int receiveEnergy(int amount, boolean simulate) { + var h = binding.energy(); + return h != null && binding.port.input() && amount > 0 + ? h.receiveEnergy(amount, simulate) + : 0; + } + @Override + public int extractEnergy(int amount, boolean simulate) { + var h = binding.energy(); + return h != null && binding.port.output() && amount > 0 + ? h.extractEnergy(amount, simulate) + : 0; + } + @Override + public int getEnergyStored() { + var h = binding.energy(); + return h == null ? 0 : h.getEnergyStored(); + } + @Override + public int getMaxEnergyStored() { + var h = binding.energy(); + return h == null ? 0 : h.getMaxEnergyStored(); + } + @Override + public boolean canExtract() { + var h = binding.energy(); + return h != null && binding.port.output() && h.canExtract(); + } + @Override + public boolean canReceive() { + var h = binding.energy(); + return h != null && binding.port.input() && h.canReceive(); + } + }; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockResources.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockResources.java new file mode 100644 index 00000000..d54d094b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockResources.java @@ -0,0 +1,22 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.minecraft.core.Direction; + +import net.neoforged.neoforge.energy.IEnergyStorage; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; +import net.neoforged.neoforge.items.IItemHandler; + +/** + * Controller resource lookup. Return role/filter/reservation-aware views, never bypass handlers. + */ +public interface MultiblockResources { + default IItemHandler multiblockItems(String resource, Direction worldFace) { + return null; + } + default IFluidHandler multiblockFluids(String resource, Direction worldFace) { + return null; + } + default IEnergyStorage multiblockEnergy(String resource, Direction worldFace) { + return null; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index b39ba194..4ea5b512 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -338,6 +338,13 @@ private void track(int index, MachineTx tx) { } private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { + @Override + public long revision() { + // Stored FE is the energy group's only recipe-matching input. Returning to the same + // amount leaves a failed match unchanged; a refill must bypass the idle retry delay. + return buffer.stored(); + } + @Override public Object statisticsResource() { return buffer; diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/ConditionalMachineModel.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/ConditionalMachineModel.java new file mode 100644 index 00000000..8b145320 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/ConditionalMachineModel.java @@ -0,0 +1,216 @@ +package zone.moddev.mc.lib.machine.neoforge.client; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.BlockModel; +import net.minecraft.client.renderer.block.model.ItemOverrides; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.client.resources.model.Material; +import net.minecraft.client.resources.model.ModelBaker; +import net.minecraft.client.resources.model.ModelState; +import net.minecraft.client.resources.model.UnbakedModel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.BlockAndTintGetter; +import net.minecraft.world.level.block.state.BlockState; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.ChunkRenderTypeSet; +import net.neoforged.neoforge.client.event.ModelEvent; +import net.neoforged.neoforge.client.model.BakedModelWrapper; +import net.neoforged.neoforge.client.model.CompositeModel; +import net.neoforged.neoforge.client.model.data.ModelData; +import net.neoforged.neoforge.client.model.geometry.IGeometryBakingContext; +import net.neoforged.neoforge.client.model.geometry.IGeometryLoader; +import net.neoforged.neoforge.client.model.geometry.IUnbakedGeometry; +import net.neoforged.neoforge.client.model.geometry.UnbakedGeometryHelper; + +import zone.moddev.mc.lib.api.capabilities.RelativeFace; +import zone.moddev.mc.lib.machine.neoforge.MachineVisualState; +import zone.moddev.mc.lib.machine.presentation.EmissiveMask; +import zone.moddev.mc.lib.machine.presentation.VisualCondition; +import zone.moddev.mc.lib.machine.presentation.VisualVariables; + +/** Static, independently conditional parts baked with the block's normal facing transform. */ +@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) +public final class ConditionalMachineModel implements IUnbakedGeometry { + private record Part(String id, BlockModel model, VisualCondition condition) { + } + private record FaceTexture(RelativeFace face, String texture, VisualCondition condition, + String emissiveMask) { + } + private final BlockModel base; + private final List parts; + private final List faceTextures; + + private ConditionalMachineModel(BlockModel base, List parts, + List faceTextures) { + this.base = base; + this.parts = List.copyOf(parts); + this.faceTextures = List.copyOf(faceTextures); + } + + @SubscribeEvent + public static void register(ModelEvent.RegisterGeometryLoaders event) { + IGeometryLoader loader = ConditionalMachineModel::read; + event.register(ResourceLocation.parse("mmdlib:conditional_machine"), loader); + } + + private static ConditionalMachineModel read(JsonObject json, + JsonDeserializationContext context) { + try { + var defaults = new HashMap(); + for (var entry : json.getAsJsonObject("variables").entrySet()) { + var value = entry.getValue().getAsJsonPrimitive(); + Object fallback = value.isBoolean() + ? value.getAsBoolean() + : value.isNumber() ? value.getAsDouble() : value.getAsString(); + VisualVariables.Kind.of(fallback); + defaults.put(entry.getKey(), fallback); + } + var parts = new ArrayList(); + var ids = new HashSet(); + ids.add("base"); + for (var element : json.has("parts") + ? json.getAsJsonArray("parts") + : new com.google.gson.JsonArray()) { + var part = element.getAsJsonObject(); + String id = part.get("id").getAsString(); + if (!ids.add(id)) + throw new JsonParseException("Duplicate or reserved model part " + id); + parts.add(new Part(id, context.deserialize(part.get("model"), BlockModel.class), + VisualCondition.compile(part.get("when").getAsString(), defaults))); + } + var faceTextures = new ArrayList(); + if (json.has("face_textures")) + for (var element : json.getAsJsonArray("face_textures")) { + var rule = element.getAsJsonObject(); + faceTextures.add(new FaceTexture( + RelativeFace.valueOf( + rule.get("face").getAsString().toUpperCase(java.util.Locale.ROOT)), + rule.get("texture").getAsString(), + VisualCondition.compile(rule.get("when").getAsString(), defaults), + rule.has("emissive_mask") + ? rule.get("emissive_mask").getAsString() + : null)); + } + return new ConditionalMachineModel( + context.deserialize(json.get("base"), BlockModel.class), parts, faceTextures); + } catch (IllegalArgumentException | NullPointerException ex) { + throw new JsonParseException("Invalid conditional machine model: " + ex.getMessage(), + ex); + } + } + + @Override + public void resolveParents(Function models, + IGeometryBakingContext context) { + base.resolveParents(models); + parts.forEach(part -> part.model().resolveParents(models)); + } + + @Override + public BakedModel bake(IGeometryBakingContext context, ModelBaker baker, + Function sprites, ModelState state, ItemOverrides overrides) { + if (!context.getRootTransform().isIdentity()) + state = UnbakedGeometryHelper.composeRootTransformIntoModelState(state, + context.getRootTransform()); + var baseModel = base.bake(baker, base, sprites, state, true); + var children = ImmutableMap.builder(); + var textureRules = new ArrayList(); + for (var rule : faceTextures) { + var face = Direction.rotate(state.getRotation().getMatrix(), + rule.face().toWorld(Direction.NORTH)); + var mask = rule.emissiveMask() == null + ? null + : readMask(sprites.apply(context.getMaterial(rule.emissiveMask()))); + textureRules.add(new FaceTextureModel.Rule(face, rule.condition(), + sprites.apply(context.getMaterial(rule.texture())), mask)); + } + children.put("base", + textureRules.isEmpty() ? baseModel : new FaceTextureModel(baseModel, textureRules)); + for (var part : parts) + children.put(part.id(), new ConditionalPart( + part.model().bake(baker, part.model(), sprites, state, true), part.condition())); + // Inventory items intentionally show the base appearance, independent of world state. + return new CompositeModel.Baked(context.isGui3d(), context.useBlockLight(), + context.useAmbientOcclusion(), + baseModel.getParticleIcon(), context.getTransforms(), overrides, children.build(), + ImmutableList.of(baseModel)); + } + + private static EmissiveMask readMask(TextureAtlasSprite sprite) { + if (sprite.contents().name() + .equals(net.minecraft.client.renderer.texture.MissingTextureAtlasSprite.getLocation())) + throw new JsonParseException("Missing emissive mask texture"); + var contents = sprite.contents(); + var image = contents.getOriginalImage(); + if (image.getWidth() != contents.width() || image.getHeight() != contents.height()) + throw new JsonParseException( + "Emissive masks must be static single-frame textures: " + contents.name()); + return EmissiveMask.read(contents.width(), contents.height(), (x, y) -> { + int rgba = image.getPixelRGBA(x, y); + int brightness = Math.max(rgba & 255, + Math.max((rgba >>> 8) & 255, (rgba >>> 16) & 255)); + return ((rgba >>> 24) & 255) >= 128 && brightness >= 128 ? 1 : 0; + }); + } + + private static final class ConditionalPart extends BakedModelWrapper { + private final VisualCondition condition; + + private ConditionalPart(BakedModel model, VisualCondition condition) { + super(model); + this.condition = condition; + } + + private boolean visible(ModelData data) { + Map values = data.get(MachineVisualState.VARIABLES); + return condition.test(values == null ? Map.of() : values); + } + + @Override + public ModelData getModelData(BlockAndTintGetter level, BlockPos pos, BlockState state, + ModelData data) { + var child = originalModel.getModelData(level, pos, state, data); + var values = data.get(MachineVisualState.VARIABLES); + return values == null + ? child + : child.derive().with(MachineVisualState.VARIABLES, values).build(); + } + + @Override + public List getQuads(BlockState state, Direction side, RandomSource random, + ModelData data, RenderType type) { + return visible(data) + ? originalModel.getQuads(state, side, random, data, type) + : List.of(); + } + + @Override + public ChunkRenderTypeSet getRenderTypes(BlockState state, RandomSource random, + ModelData data) { + return visible(data) + ? originalModel.getRenderTypes(state, random, data) + : ChunkRenderTypeSet.none(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/FaceTextureModel.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/FaceTextureModel.java new file mode 100644 index 00000000..574d5d71 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/FaceTextureModel.java @@ -0,0 +1,112 @@ +package zone.moddev.mc.lib.machine.neoforge.client; + +import java.util.List; +import java.util.Map; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.BlockAndTintGetter; +import net.minecraft.world.level.block.state.BlockState; + +import net.neoforged.neoforge.client.model.BakedModelWrapper; +import net.neoforged.neoforge.client.model.IQuadTransformer; +import net.neoforged.neoforge.client.model.data.ModelData; +import net.neoforged.neoforge.client.model.data.ModelProperty; + +import zone.moddev.mc.lib.machine.neoforge.MachineVisualState; +import zone.moddev.mc.lib.machine.presentation.EmissiveMask; +import zone.moddev.mc.lib.machine.presentation.VisualCondition; + +/** + * Replaces base-face sprites while retaining geometry, UV layout, tint, lighting and render layer. + */ +final class FaceTextureModel extends BakedModelWrapper { + record Rule(Direction face, VisualCondition condition, TextureAtlasSprite texture, + EmissiveMask mask) { + } + private record Change(BakedQuad quad, TextureAtlasSprite texture, EmissiveMask mask) { + } + + private final List rules; + private final ModelProperty> selection = new ModelProperty<>(); + private final LoadingCache> retextured = CacheBuilder.newBuilder() + .maximumSize(1024) + .build(CacheLoader.from(change -> change.mask() == null + ? List.of(retexture(change.quad(), change.texture())) + : MaskedFaceGeometry.apply(change.quad(), change.texture(), change.mask()))); + + FaceTextureModel(BakedModel base, List rules) { + super(base); + this.rules = List.copyOf(rules); + } + + @Override + public ModelData getModelData(BlockAndTintGetter level, BlockPos pos, BlockState state, + ModelData data) { + var child = originalModel.getModelData(level, pos, state, data); + var values = data.get(MachineVisualState.VARIABLES); + var result = child.derive().with(selection, select(values == null ? Map.of() : values)); + if (values != null) + result.with(MachineVisualState.VARIABLES, values); + return result.build(); + } + + @Override + public List getQuads(BlockState state, Direction side, RandomSource random) { + return getQuads(state, side, random, ModelData.EMPTY, null); + } + + @Override + public List getQuads(BlockState state, Direction side, RandomSource random, + ModelData data, RenderType type) { + var source = originalModel.getQuads(state, side, random, data, type); + var selected = data.get(selection); + if (selected == null) { + var values = data.get(MachineVisualState.VARIABLES); + selected = select(values == null ? Map.of() : values); + } + if (selected.isEmpty()) + return source; + var textures = selected; + return source.stream().flatMap(quad -> { + var replacement = textures.get(quad.getDirection()); + return (replacement == null + || (replacement.texture() == quad.getSprite() && replacement.mask() == null) + ? List.of(quad) + : retextured + .getUnchecked(new Change(quad, replacement.texture(), replacement.mask()))) + .stream(); + }).toList(); + } + + private Map select(Map snapshot) { + var selected = new java.util.EnumMap(Direction.class); + for (var rule : rules) + if (!selected.containsKey(rule.face()) && rule.condition().test(snapshot)) + selected.put(rule.face(), rule); + return Map.copyOf(selected); + } + + private static BakedQuad retexture(BakedQuad quad, TextureAtlasSprite texture) { + int[] vertices = quad.getVertices().clone(); + var original = quad.getSprite(); + for (int vertex = 0; vertex < 4; vertex++) { + int uv = vertex * IQuadTransformer.STRIDE + IQuadTransformer.UV0; + float u = original.getUOffset(Float.intBitsToFloat(vertices[uv])); + float v = original.getVOffset(Float.intBitsToFloat(vertices[uv + 1])); + vertices[uv] = Float.floatToRawIntBits(texture.getU(u)); + vertices[uv + 1] = Float.floatToRawIntBits(texture.getV(v)); + } + return new BakedQuad(vertices, quad.getTintIndex(), quad.getDirection(), texture, + quad.isShade(), quad.hasAmbientOcclusion()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/MaskedFaceGeometry.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/MaskedFaceGeometry.java new file mode 100644 index 00000000..c4007cff --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/client/MaskedFaceGeometry.java @@ -0,0 +1,100 @@ +package zone.moddev.mc.lib.machine.neoforge.client; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; + +import net.neoforged.neoforge.client.model.IQuadTransformer; + +import zone.moddev.mc.lib.machine.presentation.EmissiveMask; + +/** Partitions rectangular UV quads into lit/unlit regions without overlapping geometry. */ +final class MaskedFaceGeometry { + private MaskedFaceGeometry() { + } + + static List apply(BakedQuad quad, TextureAtlasSprite texture, EmissiveMask mask) { + int stride = IQuadTransformer.STRIDE, uv = IQuadTransformer.UV0; + var source = quad.getVertices(); + float[] u = new float[4], v = new float[4]; + float u0 = Float.POSITIVE_INFINITY, v0 = u0, u1 = Float.NEGATIVE_INFINITY, v1 = u1; + for (int i = 0; i < 4; i++) { + u[i] = quad.getSprite().getUOffset(Float.intBitsToFloat(source[i * stride + uv])); + v[i] = quad.getSprite().getVOffset(Float.intBitsToFloat(source[i * stride + uv + 1])); + u0 = Math.min(u0, u[i]); + u1 = Math.max(u1, u[i]); + v0 = Math.min(v0, v[i]); + v1 = Math.max(v1, v[i]); + } + if (u1 - u0 < 0.000001f || v1 - v0 < 0.000001f) + throw new IllegalArgumentException( + "Emissive masks require non-degenerate rectangular UVs"); + int[] corners = {-1, -1, -1, -1}; + int[] vertexCorners = new int[4]; + for (int i = 0; i < 4; i++) { + boolean right = Math.abs(u[i] - u1) < 0.00001f; + boolean bottom = Math.abs(v[i] - v1) < 0.00001f; + if ((!right && Math.abs(u[i] - u0) >= 0.00001f) + || (!bottom && Math.abs(v[i] - v0) >= 0.00001f)) + throw new IllegalArgumentException("Emissive masks require rectangular UVs"); + int corner = (right ? 1 : 0) + (bottom ? 2 : 0); + if (corners[corner] != -1) + throw new IllegalArgumentException("Duplicate emissive-mask UV corner"); + corners[corner] = i; + vertexCorners[i] = corner; + } + var result = new ArrayList(); + for (var region : mask.regions()) { + float left = Math.max(u0, (float) region.x0() / mask.width()); + float right = Math.min(u1, (float) region.x1() / mask.width()); + float top = Math.max(v0, (float) region.y0() / mask.height()); + float bottom = Math.min(v1, (float) region.y1() / mask.height()); + if (left >= right || top >= bottom) + continue; + int[] vertices = source.clone(); + for (int i = 0; i < 4; i++) { + int offset = i * stride; + float nextU = (vertexCorners[i] & 1) == 0 ? left : right; + float nextV = (vertexCorners[i] & 2) == 0 ? top : bottom; + float s = (nextU - u0) / (u1 - u0), t = (nextV - v0) / (v1 - v0); + float[] weights = {(1 - s) * (1 - t), s * (1 - t), (1 - s) * t, s * t}; + for (int axis = 0; axis < 3; axis++) { + float coordinate = 0; + for (int corner = 0; corner < 4; corner++) + coordinate += Float.intBitsToFloat( + source[corners[corner] * stride + IQuadTransformer.POSITION + axis]) + * weights[corner]; + vertices[offset + IQuadTransformer.POSITION + axis] = Float + .floatToRawIntBits(coordinate); + } + vertices[offset + IQuadTransformer.COLOR] = interpolatePacked(source, corners, + weights, IQuadTransformer.COLOR, 8); + vertices[offset + IQuadTransformer.UV2] = region.emissive() + ? LightTexture.FULL_BRIGHT + : interpolatePacked(source, corners, weights, IQuadTransformer.UV2, 16); + vertices[offset + uv] = Float.floatToRawIntBits(texture.getU(nextU)); + vertices[offset + uv + 1] = Float.floatToRawIntBits(texture.getV(nextV)); + } + result.add(new BakedQuad(vertices, quad.getTintIndex(), quad.getDirection(), texture, + !region.emissive() && quad.isShade(), + !region.emissive() && quad.hasAmbientOcclusion())); + } + return List.copyOf(result); + } + + private static int interpolatePacked(int[] vertices, int[] corners, float[] weights, int field, + int bits) { + int result = 0, mask = (1 << bits) - 1; + for (int shift = 0; shift < 32; shift += bits) { + float value = 0; + for (int corner = 0; corner < 4; corner++) + value += ((vertices[corners[corner] * IQuadTransformer.STRIDE + field] >>> shift) + & mask) * weights[corner]; + result |= (Math.round(value) & mask) << shift; + } + return result; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/BuiltinVisualVariables.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/BuiltinVisualVariables.java new file mode 100644 index 00000000..77e71788 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/BuiltinVisualVariables.java @@ -0,0 +1,119 @@ +package zone.moddev.mc.lib.machine.presentation; + +import java.util.List; + +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemInventories; +import zone.moddev.mc.lib.api.capabilities.ItemStore; +import zone.moddev.mc.lib.api.util.ChangeSignal; +import zone.moddev.mc.lib.machine.definition.MachineDefinition; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; + +/** Reusable component adapters. Null components declare the same schema with idle defaults. */ +public final class BuiltinVisualVariables { + private BuiltinVisualVariables() { + } + + public static VisualVariableProvider processor(RecipeProcessor processor) { + return vars -> vars.observedBy(processor == null + ? new ChangeSignal[0] + : new ChangeSignal[]{processor.activityChanges()}) + .add("running", false, () -> processor != null && processor.processing()) + .add("status", "idle", () -> processor == null + ? "idle" + : processor.clientState().status().orElse(RecipeProcessor.Status.IDLE).name() + .toLowerCase(java.util.Locale.ROOT)) + .add("paused", false, () -> processor != null && processor.paused()) + .observedBy(processor == null + ? new ChangeSignal[0] + : new ChangeSignal[]{processor.progressChanges()}) + .add("progress", 0.0, () -> processorProgress(processor)); + } + + private static double processorProgress(RecipeProcessor processor) { + if (processor == null) + return 0; + var state = processor.clientState(); + int duration = state.cycle().map(cycle -> cycle.time()).orElse(state.resumeTime()); + return duration <= 0 ? 0 : Math.min(1.0, (double) state.progress() / duration); + } + + public static VisualVariableProvider items(ItemStore items, List slots) { + var members = List.copyOf(slots); + var sources = items == null + ? new ChangeSignal[0] + : members.stream().map(items::storage).distinct().map(store -> store.changes()) + .toArray(ChangeSignal[]::new); + return vars -> vars.observedBy(sources) + .add("count", 0.0, () -> items == null + ? 0.0 + : members.stream().mapToDouble(i -> items.getStackInSlot(i).getCount()).sum()) + .add("fill", 0.0, () -> itemFill(items, members)) + .add("empty", true, + () -> items == null + || members.stream().allMatch(i -> items.getStackInSlot(i).isEmpty())) + .add("full", false, () -> items != null && itemFill(items, members) >= 1.0); + } + + private static double itemFill(ItemStore items, List slots) { + if (items == null || slots.isEmpty()) + return 0; + return slots.stream().mapToDouble(i -> { + var stack = items.getStackInSlot(i); + int limit = Math.min(items.getSlotLimit(i), stack.getMaxStackSize()); + return stack.isEmpty() || limit <= 0 + ? 0 + : Math.min(1.0, (double) stack.getCount() / limit); + }).average().orElse(0); + } + + public static VisualVariableProvider tank(FluidStorage fluids, int tank) { + return vars -> vars.observedBy(fluids == null + ? new ChangeSignal[0] + : new ChangeSignal[]{fluids.changes()}) + .add("amount", 0.0, + () -> fluids == null ? 0.0 : fluids.getFluidInTank(tank).getAmount()) + .add("capacity", 0.0, () -> fluids == null ? 0.0 : fluids.getTankCapacity(tank)) + .add("fill", 0.0, () -> fluids == null || fluids.getTankCapacity(tank) <= 0 + ? 0.0 + : Math.min(1.0, + (double) fluids.getFluidInTank(tank).getAmount() + / fluids.getTankCapacity(tank))) + .add("empty", true, () -> fluids == null || fluids.getFluidInTank(tank).isEmpty()); + } + + public static VisualVariableProvider energy(EnergyBuffer energy) { + return vars -> vars.observedBy(energy == null + ? new ChangeSignal[0] + : new ChangeSignal[]{energy.changes()}) + .add("stored", 0.0, () -> energy == null ? 0.0 : energy.stored()) + .add("capacity", 0.0, () -> energy == null ? 0.0 : energy.capacity()) + .add("fill", 0.0, () -> energy == null || energy.capacity() <= 0 + ? 0.0 + : Math.min(1.0, (double) energy.stored() / energy.capacity())); + } + + /** + * XML machine assembly uses the same providers for load-time validation and server sampling. + */ + public static VisualVariables forMachine(MachineDefinition definition, + RecipeProcessor processor, + ItemInventories items, FluidStorage fluids, EnergyBuffer energy) { + var vars = new VisualVariables.Builder(); + vars.component("processors." + definition.processor(), + processor == null ? processor(null) : processor); + for (var group : definition.groups()) { + var indexes = definition.inventorySlots(group.id()); + vars.component("inventories." + group.id(), + items(items == null ? null : items.inventory(group.id()), + group.members().stream().map(indexes::indexOf).toList())); + } + if (definition.fluids() != null) + for (int i = 0; i < definition.fluids().tanks().size(); i++) + vars.component("tanks." + definition.fluids().tanks().get(i).id(), tank(fluids, i)); + if (definition.power() != null) + vars.component("energy." + definition.power().id(), energy(energy)); + return vars.build(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalFaceTexture.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalFaceTexture.java new file mode 100644 index 00000000..fc148723 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalFaceTexture.java @@ -0,0 +1,14 @@ +package zone.moddev.mc.lib.machine.presentation; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.api.capabilities.RelativeFace; + +/** An ordered texture override for one machine-relative face of the base model. */ +public record ConditionalFaceTexture(String id, RelativeFace face, ResourceLocation texture, + String when, ResourceLocation emissiveMask) { + public ConditionalFaceTexture(String id, RelativeFace face, ResourceLocation texture, + String when) { + this(id, face, texture, when, null); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalModelPart.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalModelPart.java new file mode 100644 index 00000000..f22b4f19 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/ConditionalModelPart.java @@ -0,0 +1,7 @@ +package zone.moddev.mc.lib.machine.presentation; + +import net.minecraft.resources.ResourceLocation; + +/** A static child model included when its typed condition is true. */ +public record ConditionalModelPart(String id, ResourceLocation model, String when) { +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/EmissiveMask.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/EmissiveMask.java new file mode 100644 index 00000000..d9fbd11e --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/EmissiveMask.java @@ -0,0 +1,68 @@ +package zone.moddev.mc.lib.machine.presentation; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.IntBinaryOperator; + +/** + * Immutable partition of a binary mask, built once at resource load rather than while rendering. + */ +public final class EmissiveMask { + public record Region(int x0, int y0, int x1, int y1, boolean emissive) { + } + private record Run(int x0, int x1, boolean emissive) { + } + private final int width; + private final int height; + private final List regions; + + private EmissiveMask(int width, int height, List regions) { + this.width = width; + this.height = height; + this.regions = List.copyOf(regions); + } + + public int width() { + return width; + } + public int height() { + return height; + } + public List regions() { + return regions; + } + + /** + * Nonzero pixels are emissive. Adjacent equal runs merge vertically; both classes are retained. + */ + public static EmissiveMask read(int width, int height, IntBinaryOperator pixel) { + if (width < 1 || height < 1 || width > 256 || height > 256) + throw new IllegalArgumentException("Emissive mask dimensions must be 1..256 pixels"); + var result = new ArrayList(); + Map previous = Map.of(); + for (int y = 0; y < height; y++) { + var current = new HashMap(); + int x = 0; + while (x < width) { + int start = x; + boolean lit = pixel.applyAsInt(x++, y) != 0; + while (x < width && (pixel.applyAsInt(x, y) != 0) == lit) + x++; + var run = new Run(start, x, lit); + var above = previous.get(run); + current.put(run, new Region(start, above == null ? y : above.y0(), x, y + 1, lit)); + } + for (var entry : previous.entrySet()) + if (!current.containsKey(entry.getKey())) + result.add(entry.getValue()); + if (result.size() + current.size() > 4096) + throw new IllegalArgumentException( + "Emissive mask exceeds 4096 regions; simplify the mask"); + previous = current; + } + result.addAll(previous.values()); + return new EmissiveMask(width, height, result); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualCondition.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualCondition.java new file mode 100644 index 00000000..20469fa7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualCondition.java @@ -0,0 +1,228 @@ +package zone.moddev.mc.lib.machine.presentation; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** Small typed expressions for model selection. No reflection, calls, or mutable state. */ +public final class VisualCondition { + private record Term(VisualVariables.Kind kind, Function, Object> evaluate) { + } + + private final String source; + private final Term root; + private final Set variables; + + private VisualCondition(String source, Term root, Set variables) { + this.source = source; + this.root = root; + this.variables = Set.copyOf(variables); + } + + public static VisualCondition compile(String source, Map defaults) { + if (source.length() > 2048) + throw new IllegalArgumentException("Visual condition is too long"); + var parser = new Parser(source, defaults); + var root = parser.expression(0); + parser.space(); + if (parser.pos != source.length()) + throw parser.error("Unexpected token"); + parser.expect(root, VisualVariables.Kind.BOOLEAN); + return new VisualCondition(source, root, parser.variables); + } + + public boolean test(Map snapshot) { + return (Boolean) root.evaluate().apply(snapshot); + } + + public Set variables() { + return variables; + } + + @Override + public String toString() { + return source; + } + + private static final class Parser { + private final String text; + private final Map defaults; + private final Set variables = new HashSet<>(); + private int pos; + private int depth; + + private Parser(String text, Map defaults) { + this.text = text; + this.defaults = defaults; + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException(message + " at " + pos + " in " + text); + } + + private void space() { + while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) + pos++; + } + + private boolean take(String token) { + space(); + if (!text.startsWith(token, pos)) + return false; + pos += token.length(); + return true; + } + + private void expect(Term term, VisualVariables.Kind kind) { + if (term.kind() != kind) + throw error("Expected " + kind + ", got " + term.kind()); + } + + private Term expression(int minimum) { + if (++depth > 64) + throw error("Condition nesting is too deep"); + Term left = atom(); + while (true) { + space(); + String op = null; + for (String candidate : new String[]{"||", "&&", "==", "!=", "<=", ">=", "<", ">", + "+", "-", "*", "/"}) { + if (text.startsWith(candidate, pos)) { + op = candidate; + break; + } + } + int precedence = op == null ? -1 : switch (op) { + case "||" -> 0; + case "&&" -> 1; + case "==", "!=" -> 2; + case "<", ">", "<=", ">=" -> 3; + case "+", "-" -> 4; + default -> 5; + }; + if (precedence < minimum) + break; + pos += op.length(); + left = binary(op, left, expression(precedence + 1)); + } + depth--; + return left; + } + + private Term atom() { + if (take("!")) { + Term value = expression(6); + expect(value, VisualVariables.Kind.BOOLEAN); + return new Term(VisualVariables.Kind.BOOLEAN, + vars -> !(Boolean) value.evaluate().apply(vars)); + } + if (take("-")) { + Term value = expression(6); + expect(value, VisualVariables.Kind.NUMBER); + return new Term(VisualVariables.Kind.NUMBER, vars -> -number(value, vars)); + } + if (take("(")) { + Term value = expression(0); + if (!take(")")) + throw error("Expected )"); + return value; + } + if (take("$")) { + String name; + if (take("{")) { + int end = text.indexOf('}', pos); + if (end < 0) + throw error("Expected }"); + name = text.substring(pos, end); + pos = end + 1; + } else { + int start = pos; + while (pos < text.length() && (Character.isLetterOrDigit(text.charAt(pos)) + || text.charAt(pos) == '_' || text.charAt(pos) == '.')) + pos++; + name = text.substring(start, pos); + } + if (!defaults.containsKey(name)) + throw error("Unknown visual variable " + name); + variables.add(name); + Object fallback = defaults.get(name); + return new Term(VisualVariables.Kind.of(fallback), + vars -> vars.getOrDefault(name, fallback)); + } + space(); + if (pos < text.length() && (text.charAt(pos) == '\'' || text.charAt(pos) == '"')) { + char quote = text.charAt(pos++); + int start = pos; + while (pos < text.length() && text.charAt(pos) != quote) + pos++; + if (pos == text.length()) + throw error("Unterminated string"); + String value = text.substring(start, pos++); + return new Term(VisualVariables.Kind.TEXT, vars -> value); + } + if (take("true")) + return new Term(VisualVariables.Kind.BOOLEAN, vars -> true); + if (take("false")) + return new Term(VisualVariables.Kind.BOOLEAN, vars -> false); + space(); + int start = pos; + while (pos < text.length() + && (Character.isDigit(text.charAt(pos)) || text.charAt(pos) == '.')) + pos++; + if (start == pos) + throw error("Expected value"); + try { + double value = Double.parseDouble(text.substring(start, pos)); + if (!Double.isFinite(value)) + throw error("Non-finite number"); + return new Term(VisualVariables.Kind.NUMBER, vars -> value); + } catch (NumberFormatException ex) { + throw error("Invalid number"); + } + } + + private static double number(Term term, Map vars) { + return ((Number) term.evaluate().apply(vars)).doubleValue(); + } + + private Term binary(String op, Term left, Term right) { + if (op.equals("&&") || op.equals("||")) { + expect(left, VisualVariables.Kind.BOOLEAN); + expect(right, VisualVariables.Kind.BOOLEAN); + return new Term(VisualVariables.Kind.BOOLEAN, vars -> op.equals("&&") + ? (Boolean) left.evaluate().apply(vars) + && (Boolean) right.evaluate().apply(vars) + : (Boolean) left.evaluate().apply(vars) + || (Boolean) right.evaluate().apply(vars)); + } + if (op.equals("==") || op.equals("!=")) { + expect(right, left.kind()); + return new Term(VisualVariables.Kind.BOOLEAN, vars -> { + boolean equal = left.kind() == VisualVariables.Kind.NUMBER + ? number(left, vars) == number(right, vars) + : left.evaluate().apply(vars).equals(right.evaluate().apply(vars)); + return op.equals("==") == equal; + }); + } + expect(left, VisualVariables.Kind.NUMBER); + expect(right, VisualVariables.Kind.NUMBER); + if (Set.of("<", "<=", ">", ">=").contains(op)) + return new Term(VisualVariables.Kind.BOOLEAN, vars -> switch (op) { + case "<" -> number(left, vars) < number(right, vars); + case "<=" -> number(left, vars) <= number(right, vars); + case ">" -> number(left, vars) > number(right, vars); + default -> number(left, vars) >= number(right, vars); + }); + return new Term(VisualVariables.Kind.NUMBER, vars -> { + double a = number(left, vars), b = number(right, vars); + return switch (op) { + case "+" -> a + b; + case "-" -> a - b; + case "*" -> a * b; + default -> b == 0 ? 0.0 : a / b; + }; + }); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariableProvider.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariableProvider.java new file mode 100644 index 00000000..0d2d2dc7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariableProvider.java @@ -0,0 +1,7 @@ +package zone.moddev.mc.lib.machine.presentation; + +/** A component's explicit, read-only presentation values. Getters run only on the server thread. */ +@FunctionalInterface +public interface VisualVariableProvider { + void defineVisualVariables(VisualVariables.Builder variables); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariables.java b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariables.java new file mode 100644 index 00000000..41e5d233 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/presentation/VisualVariables.java @@ -0,0 +1,129 @@ +package zone.moddev.mc.lib.machine.presentation; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import zone.moddev.mc.lib.api.util.ChangeSignal; + +/** Typed declarations and immutable snapshots, independent of networking and rendering. */ +public final class VisualVariables { + public enum Kind { + BOOLEAN, NUMBER, TEXT; + + public static Kind of(Object value) { + if (value instanceof Boolean) + return BOOLEAN; + if (value instanceof Number number && Double.isFinite(number.doubleValue())) + return NUMBER; + if (value instanceof String text && text.length() <= 256) + return TEXT; + throw new IllegalArgumentException("Invalid visual value: " + value); + } + } + + private record Entry(Object fallback, Supplier value, List sources, + ChangeSignal manual) { + } + + /** Explicit invalidation for declarations without a component change signal. */ + public void changed(String name) { + var entry = entries.get(name); + if (entry == null) + throw new IllegalArgumentException("Unknown visual variable " + name); + entry.manual().changed(); + } + + /** Observe only referenced declarations; the returned action removes every subscription. */ + public Runnable subscribe(Set names, Consumer changed) { + var removers = new ArrayList(); + for (String name : names) { + var entry = entries.get(name); + if (entry == null) + throw new IllegalArgumentException("Unknown visual variable " + name); + removers.add(entry.manual().subscribe(() -> changed.accept(name))); + for (var source : entry.sources()) + removers.add(source.subscribe(() -> changed.accept(name))); + } + return () -> removers.forEach(Runnable::run); + } + + private final Map entries; + + private VisualVariables(Map entries) { + this.entries = Map.copyOf(entries); + } + + public Map defaults() { + var result = new LinkedHashMap(); + entries.forEach((name, entry) -> result.put(name, entry.fallback())); + return Map.copyOf(result); + } + + /** Sample only the variables referenced by this machine's appearance. */ + public Map snapshot(Set names) { + var result = new LinkedHashMap(); + for (String name : names) { + var entry = entries.get(name); + if (entry == null) + throw new IllegalArgumentException("Unknown visual variable " + name); + Object value = normalize(entry.value().get()); + if (Kind.of(value) != Kind.of(entry.fallback())) + throw new IllegalArgumentException("Visual variable changed type: " + name); + result.put(name, value); + } + return Map.copyOf(result); + } + + private static Object normalize(Object value) { + Kind.of(value); + return value instanceof Number number ? number.doubleValue() : value; + } + + public static final class Builder { + private final Map entries; + private final String prefix; + private final List sources; + + public Builder() { + this(new LinkedHashMap<>(), "", List.of()); + } + + private Builder(Map entries, String prefix, List sources) { + this.entries = entries; + this.prefix = prefix; + this.sources = sources; + } + + /** Change sources for subsequent declarations in this scope; getters are not polled. */ + public Builder observedBy(ChangeSignal... sources) { + return new Builder(entries, prefix, List.of(sources)); + } + + public Builder add(String name, Object fallback, Supplier value) { + String key = prefix + name; + if (key.isBlank() || key.length() > 256 || !key.matches("[a-zA-Z0-9_.:/-]+")) + throw new IllegalArgumentException("Invalid visual variable name " + key); + if (entries.size() >= 256) + throw new IllegalArgumentException("Too many visual variables"); + if (entries.putIfAbsent(key, + new Entry(normalize(fallback), java.util.Objects.requireNonNull(value), sources, + new ChangeSignal())) != null) + throw new IllegalArgumentException("Duplicate visual variable " + key); + return this; + } + + public Builder component(String name, VisualVariableProvider provider) { + provider.defineVisualVariables(new Builder(entries, prefix + name + ".", sources)); + return this; + } + + public VisualVariables build() { + return new VisualVariables(entries); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java index eb91412c..126abc9b 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/CyclePlan.java @@ -7,8 +7,6 @@ import java.util.Map; import java.util.Optional; -import org.jetbrains.annotations.Nullable; - import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; @@ -16,6 +14,8 @@ import net.minecraft.util.RandomSource; import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + import zone.moddev.mc.lib.machine.ingredient.FluidAmount; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.EnergyPart; import zone.moddev.mc.lib.machine.inventory.EnergyGroup; @@ -35,14 +35,21 @@ * removes the recipe can't change a cycle that has begun. Groups are named by key and slots and * tanks by index within the group, as in {@link RecipeMatcher}. * - *

Reserving and restoring work from these fields alone, so a plan may be built by hand. + *

+ * Reserving and restoring work from these fields alone, so a plan may be built by hand. * - * @param recipe the recipe it was made from - * @param time ticks the cycle takes - * @param items item inputs, by slot - * @param fluids fluid inputs, by tank - * @param products each product with where it lands; chance products are placed as if certain - * @param energy the energy each energy group must supply over the cycle + * @param recipe + * the recipe it was made from + * @param time + * ticks the cycle takes + * @param items + * item inputs, by slot + * @param fluids + * fluid inputs, by tank + * @param products + * each product with where it lands; chance products are placed as if certain + * @param energy + * the energy each energy group must supply over the cycle */ public record CyclePlan( ResourceLocation recipe, @@ -51,461 +58,537 @@ public record CyclePlan( List fluids, List products, Map energy) { - /** The save form. */ - public static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - ResourceLocation.CODEC.fieldOf("recipe").forGetter(CyclePlan::recipe), - Codec.intRange(1, Integer.MAX_VALUE) - .fieldOf("time") - .forGetter(CyclePlan::time), - ItemTake.CODEC - .listOf() - .optionalFieldOf("items", List.of()) - .forGetter(CyclePlan::items), - FluidTake.CODEC - .listOf() - .optionalFieldOf("fluids", List.of()) - .forGetter(CyclePlan::fluids), - Placed.CODEC - .listOf() - .optionalFieldOf("products", List.of()) - .forGetter(CyclePlan::products), - Codec.unboundedMap(Codec.STRING, Codec.LONG) - .optionalFieldOf("energy", Map.of()) - .forGetter(CyclePlan::energy)) - .apply(instance, CyclePlan::new)); - - /** What the cycle takes from one item slot. */ - public record ItemTake(String group, int slot, ItemStack stack) { - static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - Codec.STRING.fieldOf("group").forGetter(ItemTake::group), - Codec.intRange(0, Integer.MAX_VALUE) - .fieldOf("slot") - .forGetter(ItemTake::slot), - ItemStack.CODEC.fieldOf("stack").forGetter(ItemTake::stack)) - .apply(instance, ItemTake::new)); - - /** Copies the stack. */ - public ItemTake { - stack = stack.copy(); - } + /** The save form. */ + public static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + ResourceLocation.CODEC.fieldOf("recipe").forGetter(CyclePlan::recipe), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("time") + .forGetter(CyclePlan::time), + ItemTake.CODEC + .listOf() + .optionalFieldOf("items", List.of()) + .forGetter(CyclePlan::items), + FluidTake.CODEC + .listOf() + .optionalFieldOf("fluids", List.of()) + .forGetter(CyclePlan::fluids), + Placed.CODEC + .listOf() + .optionalFieldOf("products", List.of()) + .forGetter(CyclePlan::products), + Codec.unboundedMap(Codec.STRING, Codec.LONG) + .optionalFieldOf("energy", Map.of()) + .forGetter(CyclePlan::energy)) + .apply(instance, CyclePlan::new)); + + /** What the cycle reserves from one item slot, including the count retained on completion. */ + public record ItemTake(String group, int slot, ItemStack stack, int retained) { + public ItemTake(String group, int slot, ItemStack stack) { + this(group, slot, stack, 0); + } + static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + Codec.STRING.fieldOf("group").forGetter(ItemTake::group), + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("slot") + .forGetter(ItemTake::slot), + ItemStack.CODEC.fieldOf("stack").forGetter(ItemTake::stack), + Codec.intRange(0, Integer.MAX_VALUE).optionalFieldOf("retained", 0) + .forGetter(ItemTake::retained)) + .apply(instance, ItemTake::new)); - /** A copy of the stack. */ - @Override - public ItemStack stack() { - return stack.copy(); + /** Copies the stack. */ + public ItemTake { + stack = stack.copy(); + } + + /** A copy of the stack. */ + @Override + public ItemStack stack() { + return stack.copy(); + } } - } - - /** What the cycle takes from one tank. */ - public record FluidTake(String group, int tank, FluidAmount fluid) { - static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - Codec.STRING.fieldOf("group").forGetter(FluidTake::group), - Codec.intRange(0, Integer.MAX_VALUE) - .fieldOf("tank") - .forGetter(FluidTake::tank), - FluidAmount.CODEC.fieldOf("fluid").forGetter(FluidTake::fluid)) - .apply(instance, FluidTake::new)); - } - - /** A product and where it goes: how much into each slot or tank of the group. */ - public record Placed(String group, MachineProduct product, List at) { - static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - Codec.STRING.fieldOf("group").forGetter(Placed::group), - MachineProduct.CODEC.fieldOf("product").forGetter(Placed::product), - At.CODEC.listOf().fieldOf("at").forGetter(Placed::at)) - .apply(instance, Placed::new)); - - /** Copies the list. */ - public Placed { - at = List.copyOf(at); + + /** What the cycle reserves from one tank, including the mB retained on completion. */ + public record FluidTake(String group, int tank, FluidAmount fluid, int retained) { + public FluidTake(String group, int tank, FluidAmount fluid) { + this(group, tank, fluid, 0); + } + static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + Codec.STRING.fieldOf("group").forGetter(FluidTake::group), + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("tank") + .forGetter(FluidTake::tank), + FluidAmount.CODEC.fieldOf("fluid").forGetter(FluidTake::fluid), + Codec.intRange(0, Integer.MAX_VALUE).optionalFieldOf("retained", 0) + .forGetter(FluidTake::retained)) + .apply(instance, FluidTake::new)); } - } - - /** {@code amount} items or mB into slot or tank {@code index} of a group. */ - public record At(int index, int amount) { - static final Codec CODEC = - RecordCodecBuilder.create( - instance -> - instance - .group( - Codec.intRange(0, Integer.MAX_VALUE) - .fieldOf("index") - .forGetter(At::index), - Codec.intRange(1, Integer.MAX_VALUE) - .fieldOf("amount") - .forGetter(At::amount)) - .apply(instance, At::new)); - } - - /** Copies the lists and the map. */ - public CyclePlan { - items = List.copyOf(items); - fluids = List.copyOf(fluids); - products = List.copyOf(products); - energy = Map.copyOf(energy); - } - - /** One slot or tank of a group. */ - private record Spot(String group, int index) {} - - /** This cycle's plan for each slot and tank it touches, items and fluids combined per spot. */ - private record Plans(Map items, Map tanks) {} - - /** - * What running {@code recipe} would reserve now, ignoring energy; empty if it can't. Changes - * nothing. Chance products are placed as if certain. - */ - public static Optional of( - ResourceLocation id, MachineRecipe recipe, MachineInventory inventory) { - var items = new ArrayList(); - var fluids = new ArrayList(); - var energy = new LinkedHashMap(); - var taken = new HashMap(); - for (var entry : recipe.inputs().entrySet()) { - var key = entry.getKey(); - var group = inventory.group(key).orElse(null); - if (group == null) return Optional.empty(); - if (group instanceof EnergyGroup) { - long total = 0; - for (var part : entry.getValue()) { - if (!(part instanceof EnergyPart energyPart)) return Optional.empty(); - total += energyPart.amount(); + + /** A product and where it goes: how much into each slot or tank of the group. */ + public record Placed(String group, MachineProduct product, List at) { + static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + Codec.STRING.fieldOf("group").forGetter(Placed::group), + MachineProduct.CODEC.fieldOf("product").forGetter(Placed::product), + At.CODEC.listOf().fieldOf("at").forGetter(Placed::at)) + .apply(instance, Placed::new)); + + /** Copies the list. */ + public Placed { + at = List.copyOf(at); } - energy.put(key, total); - continue; - } - var held = RecipeMatcher.available(group); - var claims = new InputClaims(held); - for (var ingredient : entry.getValue()) - if (RecipeMatcher.claim(claims, group, ingredient, recipe.time()) != 0) - return Optional.empty(); - var claimed = new long[held.length]; - for (int at = 0; at < held.length; at++) { - claimed[at] = claims.claimed(at); - int amount = (int) claimed[at]; - if (amount == 0) continue; - if (group instanceof ItemGroup slots) - items.add(new ItemTake(key, at, slots.peek(at).copyWithCount(amount))); - else if (group instanceof FluidGroup tanks) - fluids.add(new FluidTake(key, at, tanks.peek(at).withAmount(amount))); - } - taken.put(key, claimed); } - var products = new ArrayList(); - for (var entry : recipe.outputs().entrySet()) { - var key = entry.getKey(); - var group = inventory.group(key).orElse(null); - if (group == null) return Optional.empty(); - var list = entry.getValue(); - var placed = place(group, list, taken.getOrDefault(key, new long[size(group)])); - if (placed == null) return Optional.empty(); - for (int i = 0; i < list.size(); i++) { - var at = new ArrayList(); - for (int slot = 0; slot < placed[i].length; slot++) - if (placed[i][slot] > 0) at.add(new At(slot, placed[i][slot])); - products.add(new Placed(key, list.get(i), at)); - } + + /** {@code amount} items or mB into slot or tank {@code index} of a group. */ + public record At(int index, int amount) { + static final Codec CODEC = RecordCodecBuilder.create( + instance -> instance + .group( + Codec.intRange(0, Integer.MAX_VALUE) + .fieldOf("index") + .forGetter(At::index), + Codec.intRange(1, Integer.MAX_VALUE) + .fieldOf("amount") + .forGetter(At::amount)) + .apply(instance, At::new)); } - return Optional.of(new CyclePlan(id, recipe.time(), items, fluids, products, energy)); - } - - /** - * Reserves the inputs and the space for the products, all or nothing: if any slot or tank - * refuses, nothing is reserved. Call it on the inventory {@link #of} looked at, before another - * cycle reserves, or it may be refused. - * - * @return whether everything was reserved - */ - public boolean reserve(MachineInventory inventory) { - var plans = plans(); - if (plans == null) return false; - try (var tx = inventory.begin()) { - for (var entry : plans.items.entrySet()) { - var spot = entry.getKey(); - var group = items(inventory, spot.group); - if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) return false; - } - for (var entry : plans.tanks.entrySet()) { - var spot = entry.getKey(); - var group = fluids(inventory, spot.group); - if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) return false; - } - tx.commit(); - return true; + + /** Copies the lists and the map. */ + public CyclePlan { + items = List.copyOf(items); + fluids = List.copyOf(fluids); + products = List.copyOf(products); + energy = Map.copyOf(energy); } - } - - /** - * Puts this plan back on storages that already hold what it reserved, for loading a saved cycle. - * Nothing is checked beyond that the inputs are still there; the room was reserved when it was - * saved. All or nothing. - * - * @return false, with no plan left behind, if a group, slot or tank is gone or no longer holds - * the reserved inputs - */ - public boolean restore(MachineInventory inventory) { - var plans = plans(); - if (plans == null) return false; - for (var entry : plans.items.entrySet()) { - var spot = entry.getKey(); - var group = items(inventory, spot.group); - if (group == null || group.free(spot.index) < entry.getValue().take()) return false; + + /** One slot or tank of a group. */ + private record Spot(String group, int index) { } - for (var entry : plans.tanks.entrySet()) { - var spot = entry.getKey(); - var group = fluids(inventory, spot.group); - if (group == null || group.free(spot.index) < entry.getValue().take()) return false; + + /** This cycle's plan for each slot and tank it touches, items and fluids combined per spot. */ + private record Plans(Map items, Map tanks) { } - for (var take : items) { - var group = items(inventory, take.group); - if (group == null) return false; - if (!ItemStack.isSameItemSameComponents(group.peek(take.slot), take.stack)) return false; + + /** + * What running {@code recipe} would reserve now, ignoring energy; empty if it can't. Changes + * nothing. Chance products are placed as if certain. + */ + public static Optional of( + ResourceLocation id, MachineRecipe recipe, MachineInventory inventory) { + return of(id, recipe, inventory, false); } - for (var take : fluids) { - var group = fluids(inventory, take.group); - if (group == null || !group.peek(take.tank).sameFluid(take.fluid)) return false; + + /** Resuming paid work keeps its recipe even if the user changed a ghost sample meanwhile. */ + public static Optional of( + ResourceLocation id, MachineRecipe recipe, MachineInventory inventory, boolean resuming) { + var items = new ArrayList(); + var fluids = new ArrayList(); + var energy = new LinkedHashMap(); + var taken = new HashMap(); + for (var entry : recipe.inputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) + return Optional.empty(); + if (resuming && group instanceof ItemGroup ghost && ghost.ghost()) { + if (!entry.getValue().stream().allMatch( + zone.moddev.mc.lib.machine.ingredient.MachineIngredient.ItemPart.class::isInstance)) + return Optional.empty(); + continue; + } + if (group instanceof EnergyGroup) { + long total = 0; + for (var part : entry.getValue()) { + if (!(part instanceof EnergyPart energyPart)) + return Optional.empty(); + total += energyPart.amount(); + } + energy.put(key, total); + continue; + } + var held = RecipeMatcher.available(group); + var claims = new InputClaims(held); + for (var ingredient : entry.getValue()) + if (RecipeMatcher.claim(claims, group, ingredient, recipe.time()) != 0) + return Optional.empty(); + if (group instanceof ItemGroup ghost && ghost.ghost()) + continue; + var claimed = new long[held.length]; + for (int at = 0; at < held.length; at++) { + claimed[at] = RecipeMatcher.consumed(claims, entry.getValue(), at); + int amount = (int) claims.claimed(at); + int retained = amount - (int) claimed[at]; + if (amount == 0) + continue; + if (group instanceof ItemGroup slots) + items + .add(new ItemTake(key, at, slots.peek(at).copyWithCount(amount), retained)); + else if (group instanceof FluidGroup tanks) + fluids.add(new FluidTake(key, at, tanks.peek(at).withAmount(amount), retained)); + } + taken.put(key, claimed); + } + var products = new ArrayList(); + for (var entry : recipe.outputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) + return Optional.empty(); + var list = entry.getValue(); + var placed = place(group, list, taken.getOrDefault(key, new long[size(group)])); + if (placed == null) + return Optional.empty(); + for (int i = 0; i < list.size(); i++) { + var at = new ArrayList(); + for (int slot = 0; slot < placed[i].length; slot++) + if (placed[i][slot] > 0) + at.add(new At(slot, placed[i][slot])); + products.add(new Placed(key, list.get(i), at)); + } + } + return Optional.of(new CyclePlan(id, recipe.time(), items, fluids, products, energy)); } - try (var tx = inventory.begin()) { - for (var entry : plans.items.entrySet()) { - var spot = entry.getKey(); - if (!items(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) return false; - } - for (var entry : plans.tanks.entrySet()) { - var spot = entry.getKey(); - if (!fluids(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) return false; - } - tx.commit(); - return true; + + /** + * Reserves the inputs and the space for the products, all or nothing: if any slot or tank + * refuses, nothing is reserved. Call it on the inventory {@link #of} looked at, before another + * cycle reserves, or it may be refused. + * + * @return whether everything was reserved + */ + public boolean reserve(MachineInventory inventory) { + var plans = plans(); + if (plans == null) + return false; + try (var tx = inventory.begin()) { + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + var group = items(inventory, spot.group); + if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) + return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + var group = fluids(inventory, spot.group); + if (group == null || !group.reserve(spot.index, entry.getValue(), tx)) + return false; + } + tx.commit(); + return true; + } } - } - /** Gives back everything {@link #reserve} or {@link #restore} reserved. */ - public void release(MachineInventory inventory) { - try (var tx = inventory.begin()) { - release(inventory, tx); - tx.commit(); + /** + * Puts this plan back on storages that already hold what it reserved, for loading a saved + * cycle. Nothing is checked beyond that the inputs are still there; the room was reserved when + * it was saved. All or nothing. + * + * @return false, with no plan left behind, if a group, slot or tank is gone or no longer holds + * the reserved inputs + */ + public boolean restore(MachineInventory inventory) { + var plans = plans(); + if (plans == null) + return false; + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + var group = items(inventory, spot.group); + if (group == null || group.free(spot.index) < entry.getValue().take()) + return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + var group = fluids(inventory, spot.group); + if (group == null || group.free(spot.index) < entry.getValue().take()) + return false; + } + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) + return false; + if (!ItemStack.isSameItemSameComponents(group.peek(take.slot), take.stack)) + return false; + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null || !group.peek(take.tank).sameFluid(take.fluid)) + return false; + } + try (var tx = inventory.begin()) { + for (var entry : plans.items.entrySet()) { + var spot = entry.getKey(); + if (!items(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) + return false; + } + for (var entry : plans.tanks.entrySet()) { + var spot = entry.getKey(); + if (!fluids(inventory, spot.group).restore(spot.index, entry.getValue(), tx)) + return false; + } + tx.commit(); + return true; + } + } + + /** Gives back everything {@link #reserve} or {@link #restore} reserved. */ + public void release(MachineInventory inventory) { + try (var tx = inventory.begin()) { + release(inventory, tx); + tx.commit(); + } + } + + /** + * Rolls each product's chance, then in one transaction releases this plan, takes the consumed + * inputs and inserts the hits where they were placed. Retained inputs stay untouched. A product + * that misses leaves its space free, and nothing is kept of the roll, so a retry rolls again. + * + * @return false, with the plan still in place and nothing changed, if an input is gone or a + * product no longer fits where it was placed + */ + public boolean finish(MachineInventory inventory, RandomSource random) { + if (plans() == null) + return false; + var hits = new ArrayList(); + for (var placed : products) + if (placed.product.chance() >= 1 || random.nextFloat() < placed.product.chance()) + hits.add(placed); + if (!canFinish(inventory, hits)) + return false; + try (var tx = inventory.begin()) { + release(inventory, tx); + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) + return false; + var held = group.peek(take.slot); + int count = take.stack.getCount(); + if (!ItemStack.isSameItemSameComponents(held, take.stack) + || held.getCount() < count) + return false; + int consumed = count - take.retained; + if (consumed > 0 && group.extract(take.slot, consumed, tx) != consumed) + return false; + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null) + return false; + var held = group.peek(take.tank); + if (!held.sameFluid(take.fluid) || held.amount() < take.fluid.amount()) + return false; + int consumed = take.fluid.amount() - take.retained; + if (consumed > 0 && group.extract(take.tank, consumed, tx) != consumed) + return false; + } + for (var placed : hits) + for (var at : placed.at) + if (!insert(inventory, placed, at, tx)) + return false; + tx.commit(); + return true; + } + } + + /** + * Whether the inputs are still there and the rolled {@code hits} could still go where they were + * placed, looking only at what the slots and tanks hold, with this cycle's takes gone. Changes + * nothing and fires nothing, so a cycle that can't finish costs no transaction. A tank's + * capacity and a slot's limit are left to the transaction. + */ + private boolean canFinish(MachineInventory inventory, List hits) { + var left = new HashMap(); + var required = new HashMap(); + for (var take : items) { + var group = items(inventory, take.group); + if (group == null) + return false; + var held = group.peek(take.slot); + var spot = new Spot(take.group, take.slot); + int total = required.merge(spot, take.stack.getCount(), Integer::sum); + int rest = left.getOrDefault(spot, held.getCount()) - take.stack.getCount() + + take.retained; + if (!ItemStack.isSameItemSameComponents(held, take.stack) || held.getCount() < total) + return false; + left.put(spot, rest); + } + for (var take : fluids) { + var group = fluids(inventory, take.group); + if (group == null) + return false; + var held = group.peek(take.tank); + var spot = new Spot(take.group, take.tank); + int total = required.merge(spot, take.fluid.amount(), Integer::sum); + int rest = left.getOrDefault(spot, held.amount()) - take.fluid.amount() + take.retained; + if (!held.sameFluid(take.fluid) || held.amount() < total) + return false; + left.put(spot, rest); + } + var added = new HashMap(); + for (var placed : hits) + for (var at : placed.at) { + var spot = new Spot(placed.group, at.index); + int total = added.merge(spot, at.amount, Integer::sum); + switch (placed.product) { + case ItemProduct item -> { + var group = items(inventory, placed.group); + if (group == null) + return false; + var held = group.peek(at.index); + int remaining = left.getOrDefault(spot, held.getCount()); + var stack = item.stack(); + if (remaining > 0 && !ItemStack.isSameItemSameComponents(held, stack)) + return false; + if (remaining + total > stack.getMaxStackSize()) + return false; + } + case FluidProduct fluid -> { + var group = fluids(inventory, placed.group); + if (group == null) + return false; + var held = group.peek(at.index); + int remaining = left.getOrDefault(spot, held.amount()); + if (remaining > 0 && !held.sameFluid(fluid.fluid())) + return false; + } + } + } + return true; + } + + private boolean insert(MachineInventory inventory, Placed placed, At at, MachineTx tx) { + return switch (placed.product) { + case ItemProduct item -> { + var group = items(inventory, placed.group); + yield group != null + && group.insert(at.index, item.stack().copyWithCount(at.amount), + tx) == at.amount; + } + case FluidProduct fluid -> { + var group = fluids(inventory, placed.group); + yield group != null + && group.insert(at.index, fluid.fluid().withAmount(at.amount), tx) == at.amount; + } + }; } - } - - /** - * Rolls each product's chance, then in one transaction releases this plan, takes the inputs and - * inserts the hits where they were placed. A product that misses leaves its space free, and - * nothing is kept of the roll, so a retry rolls again. - * - * @return false, with the plan still in place and nothing changed, if an input is gone or a - * product no longer fits where it was placed - */ - public boolean finish(MachineInventory inventory, RandomSource random) { - if (plans() == null) return false; - var hits = new ArrayList(); - for (var placed : products) - if (placed.product.chance() >= 1 || random.nextFloat() < placed.product.chance()) - hits.add(placed); - if (!canFinish(inventory, hits)) return false; - try (var tx = inventory.begin()) { - release(inventory, tx); - for (var take : items) { - var group = items(inventory, take.group); - if (group == null) return false; - var held = group.peek(take.slot); - int count = take.stack.getCount(); - if (!ItemStack.isSameItemSameComponents(held, take.stack) || held.getCount() < count) - return false; - if (group.extract(take.slot, count, tx) != count) return false; - } - for (var take : fluids) { - var group = fluids(inventory, take.group); - if (group == null) return false; - var held = group.peek(take.tank); - if (!held.sameFluid(take.fluid) || held.amount() < take.fluid.amount()) return false; - if (group.extract(take.tank, take.fluid.amount(), tx) != take.fluid.amount()) return false; - } - for (var placed : hits) - for (var at : placed.at) - if (!insert(inventory, placed, at, tx)) return false; - tx.commit(); - return true; + + private void release(MachineInventory inventory, MachineTx tx) { + var plans = plans(); + if (plans == null) + return; + plans.items.forEach( + (spot, plan) -> { + var group = items(inventory, spot.group); + if (group != null) + group.release(spot.index, plan, tx); + }); + plans.tanks.forEach( + (spot, plan) -> { + var group = fluids(inventory, spot.group); + if (group != null) + group.release(spot.index, plan, tx); + }); } - } - - /** - * Whether the inputs are still there and the rolled {@code hits} could still go where they were - * placed, looking only at what the slots and tanks hold, with this cycle's takes gone. Changes - * nothing and fires nothing, so a cycle that can't finish costs no transaction. A tank's - * capacity and a slot's limit are left to the transaction. - */ - private boolean canFinish(MachineInventory inventory, List hits) { - var left = new HashMap(); - for (var take : items) { - var group = items(inventory, take.group); - if (group == null) return false; - var held = group.peek(take.slot); - var spot = new Spot(take.group, take.slot); - int rest = left.getOrDefault(spot, held.getCount()) - take.stack.getCount(); - if (!ItemStack.isSameItemSameComponents(held, take.stack) || rest < 0) return false; - left.put(spot, rest); + + /** The takes and placements summed per slot and tank, or null if two of them clash. */ + @Nullable + private Plans plans() { + var itemPlans = new LinkedHashMap(); + var tankPlans = new LinkedHashMap(); + for (var take : items) { + if (take.retained < 0 || take.retained > take.stack.getCount()) + return null; + var plan = new SlotPlan(take.stack.getCount(), take.stack.copyWithCount(take.retained)); + if (!add(itemPlans, new Spot(take.group, take.slot), plan)) + return null; + } + for (var take : fluids) { + if (take.retained < 0 || take.retained > take.fluid.amount()) + return null; + var plan = new TankPlan(take.fluid.amount(), take.fluid.withAmount(take.retained)); + if (!add(tankPlans, new Spot(take.group, take.tank), plan)) + return null; + } + for (var placed : products) + for (var at : placed.at) { + var spot = new Spot(placed.group, at.index); + boolean merged = switch (placed.product) { + case ItemProduct item -> + add(itemPlans, spot, + new SlotPlan(0, item.stack().copyWithCount(at.amount))); + case FluidProduct fluid -> + add(tankPlans, spot, new TankPlan(0, fluid.fluid().withAmount(at.amount))); + }; + if (!merged) + return null; + } + return new Plans(itemPlans, tankPlans); } - for (var take : fluids) { - var group = fluids(inventory, take.group); - if (group == null) return false; - var held = group.peek(take.tank); - var spot = new Spot(take.group, take.tank); - int rest = left.getOrDefault(spot, held.amount()) - take.fluid.amount(); - if (!held.sameFluid(take.fluid) || rest < 0) return false; - left.put(spot, rest); + + private static boolean add(Map plans, Spot spot, SlotPlan plan) { + var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; + if (sum == null) + return false; + plans.put(spot, sum); + return true; } - var added = new HashMap(); - for (var placed : hits) - for (var at : placed.at) { - var spot = new Spot(placed.group, at.index); - int total = added.merge(spot, at.amount, Integer::sum); - switch (placed.product) { - case ItemProduct item -> { - var group = items(inventory, placed.group); - if (group == null) return false; - var held = group.peek(at.index); - int remaining = left.getOrDefault(spot, held.getCount()); - var stack = item.stack(); - if (remaining > 0 && !ItemStack.isSameItemSameComponents(held, stack)) return false; - if (remaining + total > stack.getMaxStackSize()) return false; - } - case FluidProduct fluid -> { - var group = fluids(inventory, placed.group); - if (group == null) return false; - var held = group.peek(at.index); - int remaining = left.getOrDefault(spot, held.amount()); - if (remaining > 0 && !held.sameFluid(fluid.fluid())) return false; - } + + private static boolean add(Map plans, Spot spot, TankPlan plan) { + var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; + if (sum == null) + return false; + plans.put(spot, sum); + return true; + } + + /** + * Where {@code products} land in {@code group}; null if they don't fit or are the wrong kind. + */ + @Nullable + private static int[][] place(SlotGroup group, List products, long[] taken) { + if (group instanceof ItemGroup slots) { + var stacks = new ArrayList(); + for (var product : products) { + if (!(product instanceof ItemProduct item)) + return null; + stacks.add(item.stack()); + } + return slots.place(stacks, taken); + } + if (group instanceof FluidGroup tanks) { + var amounts = new ArrayList(); + for (var product : products) { + if (!(product instanceof FluidProduct fluid)) + return null; + amounts.add(fluid.fluid()); + } + return tanks.place(amounts, taken); } - } - return true; - } - - private boolean insert(MachineInventory inventory, Placed placed, At at, MachineTx tx) { - return switch (placed.product) { - case ItemProduct item -> { - var group = items(inventory, placed.group); - yield group != null - && group.insert(at.index, item.stack().copyWithCount(at.amount), tx) == at.amount; - } - case FluidProduct fluid -> { - var group = fluids(inventory, placed.group); - yield group != null - && group.insert(at.index, fluid.fluid().withAmount(at.amount), tx) == at.amount; - } - }; - } - - private void release(MachineInventory inventory, MachineTx tx) { - var plans = plans(); - if (plans == null) return; - plans.items.forEach( - (spot, plan) -> { - var group = items(inventory, spot.group); - if (group != null) group.release(spot.index, plan, tx); - }); - plans.tanks.forEach( - (spot, plan) -> { - var group = fluids(inventory, spot.group); - if (group != null) group.release(spot.index, plan, tx); - }); - } - - /** The takes and placements summed per slot and tank, or null if two of them clash. */ - @Nullable - private Plans plans() { - var itemPlans = new LinkedHashMap(); - var tankPlans = new LinkedHashMap(); - for (var take : items) { - var plan = new SlotPlan(take.stack.getCount(), ItemStack.EMPTY); - if (!add(itemPlans, new Spot(take.group, take.slot), plan)) return null; + return null; } - for (var take : fluids) { - var plan = new TankPlan(take.fluid.amount(), FluidAmount.EMPTY); - if (!add(tankPlans, new Spot(take.group, take.tank), plan)) return null; + + private static int size(SlotGroup group) { + return switch (group) { + case ItemGroup slots -> slots.size(); + case FluidGroup tanks -> tanks.tanks(); + case EnergyGroup energy -> 0; + }; } - for (var placed : products) - for (var at : placed.at) { - var spot = new Spot(placed.group, at.index); - boolean merged = - switch (placed.product) { - case ItemProduct item -> - add(itemPlans, spot, new SlotPlan(0, item.stack().copyWithCount(at.amount))); - case FluidProduct fluid -> - add(tankPlans, spot, new TankPlan(0, fluid.fluid().withAmount(at.amount))); - }; - if (!merged) return null; - } - return new Plans(itemPlans, tankPlans); - } - - private static boolean add(Map plans, Spot spot, SlotPlan plan) { - var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; - if (sum == null) return false; - plans.put(spot, sum); - return true; - } - - private static boolean add(Map plans, Spot spot, TankPlan plan) { - var sum = plans.containsKey(spot) ? plans.get(spot).plus(plan) : plan; - if (sum == null) return false; - plans.put(spot, sum); - return true; - } - - /** Where {@code products} land in {@code group}; null if they don't fit or are the wrong kind. */ - @Nullable - private static int[][] place(SlotGroup group, List products, long[] taken) { - if (group instanceof ItemGroup slots) { - var stacks = new ArrayList(); - for (var product : products) { - if (!(product instanceof ItemProduct item)) return null; - stacks.add(item.stack()); - } - return slots.place(stacks, taken); + + @Nullable + private static ItemGroup items(MachineInventory inventory, String key) { + return inventory.group(key).orElse(null) instanceof ItemGroup group ? group : null; } - if (group instanceof FluidGroup tanks) { - var amounts = new ArrayList(); - for (var product : products) { - if (!(product instanceof FluidProduct fluid)) return null; - amounts.add(fluid.fluid()); - } - return tanks.place(amounts, taken); + + @Nullable + private static FluidGroup fluids(MachineInventory inventory, String key) { + return inventory.group(key).orElse(null) instanceof FluidGroup group ? group : null; } - return null; - } - - private static int size(SlotGroup group) { - return switch (group) { - case ItemGroup slots -> slots.size(); - case FluidGroup tanks -> tanks.tanks(); - case EnergyGroup energy -> 0; - }; - } - - @Nullable - private static ItemGroup items(MachineInventory inventory, String key) { - return inventory.group(key).orElse(null) instanceof ItemGroup group ? group : null; - } - - @Nullable - private static FluidGroup fluids(MachineInventory inventory, String key) { - return inventory.group(key).orElse(null) instanceof FluidGroup group ? group : null; - } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java index 56f1bce9..15adcb67 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/InputClaims.java @@ -10,66 +10,75 @@ * sharing could meet every claim so far, and the order of the ingredients doesn't matter. */ public final class InputClaims { - private final long[] held; - private final long[] free; - private final List accepts = new ArrayList<>(); - private final List claims = new ArrayList<>(); + private final long[] held; + private final long[] free; + private final List accepts = new ArrayList<>(); + private final List claims = new ArrayList<>(); - /** - * @param held what each entry holds - */ - public InputClaims(long[] held) { - this.held = held.clone(); - free = held.clone(); - } + /** + * @param held + * what each entry holds + */ + public InputClaims(long[] held) { + this.held = held.clone(); + free = held.clone(); + } + + /** + * Claims up to {@code need} from entries {@code matches} accepts. + * + * @return what's still needed; 0 when the claim is met + */ + public long claim(long need, IntPredicate matches) { + var accepted = new boolean[held.length]; + for (int entry = 0; entry < held.length; entry++) + accepted[entry] = held[entry] > 0 && matches.test(entry); + accepts.add(accepted); + claims.add(new long[held.length]); + int part = claims.size() - 1; + while (need > 0) { + long found = find(part, need, new boolean[held.length]); + if (found == 0) + break; + need -= found; + } + return need; + } - /** - * Claims up to {@code need} from entries {@code matches} accepts. - * - * @return what's still needed; 0 when the claim is met - */ - public long claim(long need, IntPredicate matches) { - var accepted = new boolean[held.length]; - for (int entry = 0; entry < held.length; entry++) - accepted[entry] = held[entry] > 0 && matches.test(entry); - accepts.add(accepted); - claims.add(new long[held.length]); - int part = claims.size() - 1; - while (need > 0) { - long found = find(part, need, new boolean[held.length]); - if (found == 0) break; - need -= found; + /** The total claimed from {@code entry}. */ + public long claimed(int entry) { + return held[entry] - free[entry]; } - return need; - } - /** The total claimed from {@code entry}. */ - public long claimed(int entry) { - return held[entry] - free[entry]; - } + /** The final allocation of one claim, after later claims may have moved it. */ + public long claimed(int part, int entry) { + return claims.get(part)[entry]; + } - /** - * Finds up to {@code amount} for {@code part} on one entry, from what's free there and by moving - * other parts' claims elsewhere; returns how much it found. - */ - private long find(int part, long amount, boolean[] visited) { - for (int entry = 0; entry < held.length; entry++) { - if (!accepts.get(part)[entry] || visited[entry]) continue; - visited[entry] = true; - long found = Math.min(free[entry], amount); - free[entry] -= found; - for (int other = 0; other < claims.size() && found < amount; other++) { - long claimed = claims.get(other)[entry]; - if (other == part || claimed == 0) continue; - long moved = find(other, Math.min(amount - found, claimed), visited); - claims.get(other)[entry] -= moved; - found += moved; - } - if (found > 0) { - claims.get(part)[entry] += found; - return found; - } + /** + * Finds up to {@code amount} for {@code part} on one entry, from what's free there and by + * moving other parts' claims elsewhere; returns how much it found. + */ + private long find(int part, long amount, boolean[] visited) { + for (int entry = 0; entry < held.length; entry++) { + if (!accepts.get(part)[entry] || visited[entry]) + continue; + visited[entry] = true; + long found = Math.min(free[entry], amount); + free[entry] -= found; + for (int other = 0; other < claims.size() && found < amount; other++) { + long claimed = claims.get(other)[entry]; + if (other == part || claimed == 0) + continue; + long moved = find(other, Math.min(amount - found, claimed), visited); + claims.get(other)[entry] -= moved; + found += moved; + } + if (found > 0) { + claims.get(part)[entry] += found; + return found; + } + } + return 0; } - return 0; - } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java index 9b9a9215..ca451213 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/RecipeMatcher.java @@ -9,10 +9,10 @@ import net.minecraft.world.item.ItemStack; import zone.moddev.mc.lib.machine.ingredient.FluidAmount; -import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.EnergyPart; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.FluidPart; import zone.moddev.mc.lib.machine.ingredient.MachineIngredient.ItemPart; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; import zone.moddev.mc.lib.machine.inventory.EnergyGroup; import zone.moddev.mc.lib.machine.inventory.FluidGroup; import zone.moddev.mc.lib.machine.inventory.ItemGroup; @@ -27,203 +27,240 @@ * outputs all or nothing. Groups are joined by key. * *

    - *
  • Item and fluid parts in one group share its slots and tanks through {@link InputClaims}: - * nothing counts twice, and a recipe matches whenever some sharing meets every part, whatever - * order they're listed in. - *
  • Fluids are totalled across the group's tanks of fluids in the part's set. - *
  • Energy only needs one tick's share to start, {@code ceil(amount / time)}, because it is - * drawn while the recipe runs. + *
  • Item and fluid parts in one group share its slots and tanks through {@link InputClaims}: + * nothing counts twice, and a recipe matches whenever some sharing meets every part, whatever order + * they're listed in. + *
  • Fluids are totalled across the group's tanks of fluids in the part's set. + *
  • Energy only needs one tick's share to start, {@code ceil(amount / time)}, because it is drawn + * while the recipe runs. *
*/ public final class RecipeMatcher { - private RecipeMatcher() {} + private RecipeMatcher() { + } - /** - * Whether {@code recipe} can start: every input present and room for every product once the - * inputs are gone, counting chance products as certain. A product may land in the slot or tank - * its own input empties. Changes nothing, and no storage's change callback runs. - */ - public static MatchResult check(MachineRecipe recipe, MachineInventory inventory) { - var taken = new HashMap(); - for (var entry : recipe.inputs().entrySet()) { - var key = entry.getKey(); - var group = inventory.group(key).orElse(null); - if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); - var result = checkInputs(key, group, entry.getValue(), recipe.time(), taken); - if (!result.ok()) return result; + /** + * Whether {@code recipe} can start: every input present and room for every product once the + * consumed inputs are gone, counting chance products as certain. A product may land in the slot + * or tank its own input empties. Changes nothing, and no storage's change callback runs. + */ + public static MatchResult check(MachineRecipe recipe, MachineInventory inventory) { + var taken = new HashMap(); + for (var entry : recipe.inputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) + return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); + var result = checkInputs(key, group, entry.getValue(), recipe.time(), taken); + if (!result.ok()) + return result; + } + for (var entry : recipe.outputs().entrySet()) { + var key = entry.getKey(); + var group = inventory.group(key).orElse(null); + if (group == null) + return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); + var result = checkOutputs(key, group, entry.getValue(), taken.get(key)); + if (!result.ok()) + return result; + } + return MatchResult.OK; } - for (var entry : recipe.outputs().entrySet()) { - var key = entry.getKey(); - var group = inventory.group(key).orElse(null); - if (group == null) return new MatchResult(MatchResult.Reason.NO_GROUP, key, -1); - var result = checkOutputs(key, group, entry.getValue(), taken.get(key)); - if (!result.ok()) return result; + + /** + * Whether the machine holds at least some of one of {@code recipe}'s item or fluid inputs, + * which is what makes a missing input worth reporting. Stored energy doesn't count, because a + * powered machine with nothing in it hasn't begun on any recipe. A recipe that needs only + * energy always passes. Changes nothing. + */ + public static boolean holdsSomeInput(MachineRecipe recipe, MachineInventory inventory) { + boolean energyOnly = true; + for (var entry : recipe.inputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + for (var part : entry.getValue()) { + if (part instanceof EnergyPart) + continue; + energyOnly = false; + if (group == null) + continue; + long need = claim(new InputClaims(available(group)), group, part, recipe.time()); + if (need >= 0 && need < part.amount()) + return true; + } + } + return energyOnly; } - return MatchResult.OK; - } - /** - * Whether the machine holds at least some of one of {@code recipe}'s item or fluid inputs, which - * is what makes a missing input worth reporting. Stored energy doesn't count, because a powered - * machine with nothing in it hasn't begun on any recipe. A recipe that needs only energy always - * passes. Changes nothing. - */ - public static boolean holdsSomeInput(MachineRecipe recipe, MachineInventory inventory) { - boolean energyOnly = true; - for (var entry : recipe.inputs().entrySet()) { - var group = inventory.group(entry.getKey()).orElse(null); - for (var part : entry.getValue()) { - if (part instanceof EnergyPart) continue; - energyOnly = false; - if (group == null) continue; - long need = claim(new InputClaims(available(group)), group, part, recipe.time()); - if (need >= 0 && need < part.amount()) return true; - } + /** + * Checks all inputs and takes only consumed item and fluid inputs in one transaction, all or + * nothing. Energy is not taken; it is drawn per tick while the recipe runs. + * + * @return whether every input was present and every consumed input was taken + */ + public static boolean consume(MachineRecipe recipe, MachineInventory inventory) { + try (var tx = inventory.begin()) { + for (var entry : recipe.inputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + if (group == null) + return false; + if (!take(group, entry.getValue(), tx)) + return false; + } + tx.commit(); + return true; + } } - return energyOnly; - } - /** - * Takes the item and fluid inputs in one transaction, all or nothing. Energy is not taken; it is - * drawn per tick while the recipe runs. - * - * @return whether every input was taken - */ - public static boolean consume(MachineRecipe recipe, MachineInventory inventory) { - try (var tx = inventory.begin()) { - for (var entry : recipe.inputs().entrySet()) { - var group = inventory.group(entry.getKey()).orElse(null); - if (group == null) return false; - if (!take(group, entry.getValue(), tx)) return false; - } - tx.commit(); - return true; + /** + * Rolls each product's chance and inserts the hits in one transaction, all or nothing. + * + * @return whether everything rolled fitted; if not, nothing was inserted + */ + public static boolean produce( + MachineRecipe recipe, MachineInventory inventory, RandomSource random) { + try (var tx = inventory.begin()) { + for (var entry : recipe.outputs().entrySet()) { + var group = inventory.group(entry.getKey()).orElse(null); + if (group == null) + return false; + for (var product : entry.getValue()) { + if (product.chance() < 1 && random.nextFloat() >= product.chance()) + continue; + if (!put(group, product, tx)) + return false; + } + } + tx.commit(); + return true; + } } - } - /** - * Rolls each product's chance and inserts the hits in one transaction, all or nothing. - * - * @return whether everything rolled fitted; if not, nothing was inserted - */ - public static boolean produce( - MachineRecipe recipe, MachineInventory inventory, RandomSource random) { - try (var tx = inventory.begin()) { - for (var entry : recipe.outputs().entrySet()) { - var group = inventory.group(entry.getKey()).orElse(null); - if (group == null) return false; - for (var product : entry.getValue()) { - if (product.chance() < 1 && random.nextFloat() >= product.chance()) continue; - if (!put(group, product, tx)) return false; + /** Checks one group's inputs, and records what {@link #take} would take from it. */ + private static MatchResult checkInputs( + String key, + SlotGroup group, + List ingredients, + int time, + Map taken) { + long[] held = available(group); + var claims = new InputClaims(held); + for (int i = 0; i < ingredients.size(); i++) { + long need = claim(claims, group, ingredients.get(i), time); + if (need < 0) + return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); + if (need > 0) + return new MatchResult(MatchResult.Reason.MISSING_INPUT, key, i); } - } - tx.commit(); - return true; + var claimed = new long[held.length]; + for (int entry = 0; entry < held.length; entry++) + claimed[entry] = group instanceof ItemGroup items && items.ghost() + ? 0 + : consumed(claims, ingredients, entry); + taken.put(key, claimed); + return MatchResult.OK; } - } - /** Checks one group's inputs, and records what {@link #take} would take from it. */ - private static MatchResult checkInputs( - String key, - SlotGroup group, - List ingredients, - int time, - Map taken) { - long[] held = available(group); - var claims = new InputClaims(held); - for (int i = 0; i < ingredients.size(); i++) { - long need = claim(claims, group, ingredients.get(i), time); - if (need < 0) return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); - if (need > 0) return new MatchResult(MatchResult.Reason.MISSING_INPUT, key, i); + /** Checks one group's products; {@code taken} is what its inputs use, null when it has none. */ + private static MatchResult checkOutputs( + String key, SlotGroup group, List products, long[] taken) { + var stacks = new ArrayList(); + var fluids = new ArrayList(); + for (int i = 0; i < products.size(); i++) { + boolean fits; + if (products.get(i) instanceof ItemProduct item && group instanceof ItemGroup items) { + stacks.add(item.stack()); + fits = taken == null ? items.fits(stacks) : items.fits(stacks, taken); + } else if (products.get(i) instanceof FluidProduct fluid + && group instanceof FluidGroup tanks) { + fluids.add(fluid.fluid()); + fits = taken == null ? tanks.fits(fluids) : tanks.fits(fluids, taken); + } else + return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); + if (!fits) + return new MatchResult(MatchResult.Reason.NO_ROOM, key, i); + } + return MatchResult.OK; } - var claimed = new long[held.length]; - for (int entry = 0; entry < held.length; entry++) claimed[entry] = claims.claimed(entry); - taken.put(key, claimed); - return MatchResult.OK; - } - /** Checks one group's products; {@code taken} is what its inputs use, null when it has none. */ - private static MatchResult checkOutputs( - String key, SlotGroup group, List products, long[] taken) { - var stacks = new ArrayList(); - var fluids = new ArrayList(); - for (int i = 0; i < products.size(); i++) { - boolean fits; - if (products.get(i) instanceof ItemProduct item && group instanceof ItemGroup items) { - stacks.add(item.stack()); - fits = taken == null ? items.fits(stacks) : items.fits(stacks, taken); - } else if (products.get(i) instanceof FluidProduct fluid - && group instanceof FluidGroup tanks) { - fluids.add(fluid.fluid()); - fits = taken == null ? tanks.fits(fluids) : tanks.fits(fluids, taken); - } else return new MatchResult(MatchResult.Reason.WRONG_KIND, key, i); - if (!fits) return new MatchResult(MatchResult.Reason.NO_ROOM, key, i); + /** What each slot or tank of {@code group} has free, or the stored energy as one entry. */ + static long[] available(SlotGroup group) { + return switch (group) { + case ItemGroup items -> { + var counts = new long[items.size()]; + for (int slot = 0; slot < counts.length; slot++) + counts[slot] = items.free(slot); + yield counts; + } + case FluidGroup fluids -> { + var amounts = new long[fluids.tanks()]; + for (int tank = 0; tank < amounts.length; tank++) + amounts[tank] = fluids.free(tank); + yield amounts; + } + case EnergyGroup energy -> new long[]{energy.stored()}; + }; } - return MatchResult.OK; - } - /** What each slot or tank of {@code group} has free, or the stored energy as one entry. */ - static long[] available(SlotGroup group) { - return switch (group) { - case ItemGroup items -> { - var counts = new long[items.size()]; - for (int slot = 0; slot < counts.length; slot++) counts[slot] = items.free(slot); - yield counts; - } - case FluidGroup fluids -> { - var amounts = new long[fluids.tanks()]; - for (int tank = 0; tank < amounts.length; tank++) amounts[tank] = fluids.free(tank); - yield amounts; - } - case EnergyGroup energy -> new long[] {energy.stored()}; - }; - } + /** Adds {@code ingredient} to {@code claims}; returns what's still needed, -1 if wrong kind. */ + static long claim( + InputClaims claims, SlotGroup group, MachineIngredient ingredient, int time) { + return switch (ingredient) { + case ItemPart part when group instanceof ItemGroup items -> + claims.claim(part.count(), slot -> part.items().test(items.peek(slot))); + case FluidPart part when group instanceof FluidGroup fluids -> + claims.claim( + part.millibuckets(), tank -> part.fluids().contains(fluids.peek(tank).fluid())); + case EnergyPart part when group instanceof EnergyGroup -> + claims.claim(Math.ceilDiv(part.amount(), time), any -> true); + default -> -1; + }; + } - /** Adds {@code ingredient} to {@code claims}; returns what's still needed, -1 if wrong kind. */ - static long claim( - InputClaims claims, SlotGroup group, MachineIngredient ingredient, int time) { - return switch (ingredient) { - case ItemPart part when group instanceof ItemGroup items -> - claims.claim(part.count(), slot -> part.items().test(items.peek(slot))); - case FluidPart part when group instanceof FluidGroup fluids -> - claims.claim( - part.millibuckets(), tank -> part.fluids().contains(fluids.peek(tank).fluid())); - case EnergyPart part when group instanceof EnergyGroup -> - claims.claim(Math.ceilDiv(part.amount(), time), any -> true); - default -> -1; - }; - } + /** Takes what {@code ingredients} claim from {@code group}. Energy groups give nothing here. */ + private static boolean take(SlotGroup group, List ingredients, + MachineTx tx) { + if (group instanceof EnergyGroup) + return ingredients.stream().allMatch(EnergyPart.class::isInstance); + long[] held = available(group); + var claims = new InputClaims(held); + for (var ingredient : ingredients) + if (claim(claims, group, ingredient, 1) != 0) + return false; + if (group instanceof ItemGroup items && items.ghost()) + return true; + for (int entry = 0; entry < held.length; entry++) { + int amount = (int) consumed(claims, ingredients, entry); + if (amount == 0) + continue; + int taken = switch (group) { + case ItemGroup items -> items.extract(entry, amount, tx); + case FluidGroup fluids -> fluids.extract(entry, amount, tx); + case EnergyGroup energy -> 0; + }; + if (taken != amount) + return false; + } + return true; + } - /** Takes what {@code ingredients} claim from {@code group}. Energy groups give nothing here. */ - private static boolean take(SlotGroup group, List ingredients, MachineTx tx) { - if (group instanceof EnergyGroup) - return ingredients.stream().allMatch(EnergyPart.class::isInstance); - long[] held = available(group); - var claims = new InputClaims(held); - for (var ingredient : ingredients) if (claim(claims, group, ingredient, 1) != 0) return false; - for (int entry = 0; entry < held.length; entry++) { - int amount = (int) claims.claimed(entry); - if (amount == 0) continue; - int taken = - switch (group) { - case ItemGroup items -> items.extract(entry, amount, tx); - case FluidGroup fluids -> fluids.extract(entry, amount, tx); - case EnergyGroup energy -> 0; - }; - if (taken != amount) return false; + /** Only consumed claims free capacity; retained inputs stay in their original slots/tanks. */ + static long consumed(InputClaims claims, List ingredients, int entry) { + long amount = 0; + for (int part = 0; part < ingredients.size(); part++) + if (ingredients.get(part).consumed()) + amount += claims.claimed(part, entry); + return amount; } - return true; - } - private static boolean put(SlotGroup group, MachineProduct product, MachineTx tx) { - return switch (product) { - case ItemProduct item when group instanceof ItemGroup items -> { - var stack = item.stack(); - yield items.insert(stack, tx) == stack.getCount(); - } - case FluidProduct fluid when group instanceof FluidGroup tanks -> - tanks.insert(fluid.fluid(), tx) == fluid.fluid().amount(); - default -> false; - }; - } + private static boolean put(SlotGroup group, MachineProduct product, MachineTx tx) { + return switch (product) { + case ItemProduct item when group instanceof ItemGroup items -> { + var stack = item.stack(); + yield items.insert(stack, tx) == stack.getCount(); + } + case FluidProduct fluid when group instanceof FluidGroup tanks -> + tanks.insert(fluid.fluid(), tx) == fluid.fluid().amount(); + default -> false; + }; + } } diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index 4a3afe1a..7dab31f0 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -1,4 +1,5 @@ { + "screen.mmdlib.face.unavailable": "Unavailable on this machine", "screen.mmdlib.tank.name": "Tank %s", "screen.mmdlib.tank.empty": "Empty", "screen.mmdlib.tank.contents": "%s mB of %s", @@ -244,6 +245,8 @@ "jei.mmdlib.crushing": "Crackhammer", "jei.mmdlib.crushing.mined": "Also crushes drops when mining", "jei.mmdlib.machine.chance": "%s%% chance", + "jei.mmdlib.machine.not_consumed": "Not consumed", + "jei.mmdlib.machine.required_fluid": "Requires %s mB", "jei.mmdlib.machine.energy": "%s FE", "jei.mmdlib.machine.time": "%s s", "item.mmdlib.copper_arrow": "Copper Arrow", @@ -389,5 +392,21 @@ "screen.mmdlib.stats.empty": "No statistics available.", "screen.mmdlib.stats.tip": "Statistics and available timescales are supplied by the machine.", "item.mmdlib.fluid_bottle": "Fluid Bottle", - "item.mmdlib.fluid_bottle.filled": "Bottle of %s" + "item.mmdlib.fluid_bottle.filled": "Bottle of %s", + "screen.mmdlib.recipe_selector.choose": "Choose output", + "screen.mmdlib.recipe_selector.produce": "Produce", + "screen.mmdlib.recipe_selector.none": "No recipe selected", + "screen.mmdlib.recipe_selector.missing": "Recipe unavailable", + "screen.mmdlib.recipe_selector.choose_hint": "Click to choose a recipe", + "screen.mmdlib.recipe_selector.help": "Click to choose; right-click to clear. Items are not consumed.", + "screen.mmdlib.recipe_selector.no_matches": "No matching recipes", + "screen.mmdlib.recipe_selector.cost": "%s FE / %s ticks", + "screen.mmdlib.recipe_selector.working": "Producing %s", + "screen.mmdlib.recipe_selector.finishing": "Finishing %s first", + "screen.mmdlib.recipe_selector.queued_help": "The selected recipe starts after the current cycle finishes.", + "screen.mmdlib.recipe_selector.reason.ok": "Ready", + "screen.mmdlib.recipe_selector.reason.missing_input": "Needs %s (or currently reserved)", + "screen.mmdlib.recipe_selector.reason.no_room": "No room in %s", + "screen.mmdlib.recipe_selector.reason.no_group": "Missing resource group: %s", + "screen.mmdlib.recipe_selector.reason.wrong_kind": "Incompatible resource group: %s" } diff --git a/src/main/resources/mmdlib/machine/base_powered_machine.xml b/src/main/resources/mmdlib/machine/base_powered_machine.xml index 8b0b538f..8e45df1c 100644 --- a/src/main/resources/mmdlib/machine/base_powered_machine.xml +++ b/src/main/resources/mmdlib/machine/base_powered_machine.xml @@ -1,10 +1,11 @@ - + + - + diff --git a/src/main/resources/mmdlib/schema/screen.xsd b/src/main/resources/mmdlib/schema/screen.xsd index bc23b4eb..97643f77 100644 --- a/src/main/resources/mmdlib/schema/screen.xsd +++ b/src/main/resources/mmdlib/schema/screen.xsd @@ -151,6 +151,7 @@ + @@ -473,6 +474,16 @@ + + + + + + + + + + diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java index 7d48e567..5cfbcc68 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/CyclePlanSmoke.java @@ -27,341 +27,345 @@ import zone.moddev.mc.lib.machine.inventory.MachineInventory; import zone.moddev.mc.lib.machine.inventory.SlotPlan; import zone.moddev.mc.lib.machine.neoforge.StorageGroups; -import zone.moddev.mc.lib.machine.recipe.CyclePlan; import zone.moddev.mc.lib.machine.recipe.CyclePlan.At; import zone.moddev.mc.lib.machine.recipe.CyclePlan.FluidTake; import zone.moddev.mc.lib.machine.recipe.CyclePlan.ItemTake; import zone.moddev.mc.lib.machine.recipe.CyclePlan.Placed; +import zone.moddev.mc.lib.machine.recipe.CyclePlan; import zone.moddev.mc.lib.machine.recipe.MachineProduct.ItemProduct; /** Checks one cycle's plan end to end with the crusher recipe: build, reserve, restore, finish. */ final class CyclePlanSmoke { - private static final ResourceLocation ID = ResourceLocation.parse("example:crushing/copper"); + private static final ResourceLocation ID = ResourceLocation.parse("example:crushing/copper"); - private CyclePlanSmoke() {} + private CyclePlanSmoke() { + } - /** A crusher's storages: ore in slot 0, output in 1 and 2, byproduct in 3. */ - private static final class Crusher { - final ItemStorage items = new ItemStorage(4).role(Role.INPUT, 0).role(Role.OUTPUT, 1, 2, 3); - final FluidStorage fluids = new FluidStorage(); - final EnergyBuffer energy = new EnergyBuffer(10_000_000_000L, 1_000); - final MachineInventory inventory; + /** A crusher's storages: ore in slot 0, output in 1 and 2, byproduct in 3. */ + private static final class Crusher { + final ItemStorage items = new ItemStorage(4).role(Role.INPUT, 0).role(Role.OUTPUT, 1, 2, 3); + final FluidStorage fluids = new FluidStorage(); + final EnergyBuffer energy = new EnergyBuffer(10_000_000_000L, 1_000); + final MachineInventory inventory; - Crusher(int ore, int water) { - fluids.addTank(4_000, Role.INPUT); - items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); - fluids.tank(0).fill(new FluidStack(Fluids.WATER, water), FluidAction.EXECUTE); - inventory = - MachineInventory.builder() - .group("input", StorageGroups.items(items, 0)) - .group("output", StorageGroups.items(items, 1, 2)) - .group("byproduct", StorageGroups.items(items, 3)) - .group("water", StorageGroups.fluids(fluids, 0)) - .group("energy", StorageGroups.energy(energy)) - .build(); - } + Crusher(int ore, int water) { + fluids.addTank(4_000, Role.INPUT); + items.insertResult(0, new ItemStack(Items.COPPER_ORE, ore), false); + fluids.tank(0).fill(new FluidStack(Fluids.WATER, water), FluidAction.EXECUTE); + inventory = MachineInventory.builder() + .group("input", StorageGroups.items(items, 0)) + .group("output", StorageGroups.items(items, 1, 2)) + .group("byproduct", StorageGroups.items(items, 3)) + .group("water", StorageGroups.fluids(fluids, 0)) + .group("energy", StorageGroups.energy(energy)) + .build(); + } - boolean unplanned() { - for (int slot = 0; slot < 4; slot++) if (!items.plan(slot).isNone()) return false; - return fluids.plan(0).isNone(); + boolean unplanned() { + for (int slot = 0; slot < 4; slot++) + if (!items.plan(slot).isNone()) + return false; + return fluids.plan(0).isNone(); + } } - } - static void check(ServerLevel level) { - var access = level.registryAccess(); - var jsonOps = RegistryOps.create(JsonOps.INSTANCE, access); - var crusher = MachineRecipeSmoke.recipe(jsonOps, MachineRecipeSmoke.CRUSHER); - var none = ItemStack.EMPTY; - var rawCopper = new ItemStack(Items.RAW_COPPER, 2); + static void check(ServerLevel level) { + ReusableInputSmoke.check(level); + var access = level.registryAccess(); + var jsonOps = RegistryOps.create(JsonOps.INSTANCE, access); + var crusher = MachineRecipeSmoke.recipe(jsonOps, MachineRecipeSmoke.CRUSHER); + var none = ItemStack.EMPTY; + var rawCopper = new ItemStack(Items.RAW_COPPER, 2); - // of: what the crusher would reserve, with no energy stored. - var start = new Crusher(3, 1_000); - var plan = CyclePlan.of(ID, crusher, start.inventory).orElseThrow(); - require(plan.recipe().equals(ID) && plan.time() == 200, "recipe and time"); - require(plan.items().size() == 1, "one item take"); - var ore = plan.items().getFirst(); - require( - ore.group().equals("input") - && ore.slot() == 0 - && ItemStack.matches(ore.stack(), new ItemStack(Items.COPPER_ORE)), - "takes 1 ore from slot 0"); - require(plan.fluids().size() == 1, "one fluid take"); - var water = plan.fluids().getFirst(); - require( - water.group().equals("water") && water.tank() == 0 && water.fluid().amount() == 250, - "takes 250 mB from tank 0"); - require(plan.products().size() == 2, "two products"); - var copper = plan.products().getFirst(); - require( - copper.group().equals("output") - && copper.product() instanceof ItemProduct item - && ItemStack.matches(item.stack(), rawCopper) - && copper.at().equals(List.of(new At(0, 2))), - "2 raw copper at group slot 0"); - require(plan.energy().equals(Map.of("energy", 4_000L)), "energy " + plan.energy()); - require(start.unplanned(), "of changes nothing"); - require( - CyclePlan.of(ID, crusher, new Crusher(0, 1_000).inventory).isEmpty(), "no ore, no plan"); - require( - CyclePlan.of(ID, crusher, new Crusher(3, 249).inventory).isEmpty(), "too little water"); + // of: what the crusher would reserve, with no energy stored. + var start = new Crusher(3, 1_000); + var plan = CyclePlan.of(ID, crusher, start.inventory).orElseThrow(); + require(plan.recipe().equals(ID) && plan.time() == 200, "recipe and time"); + require(plan.items().size() == 1, "one item take"); + var ore = plan.items().getFirst(); + require( + ore.group().equals("input") + && ore.slot() == 0 + && ItemStack.matches(ore.stack(), new ItemStack(Items.COPPER_ORE)), + "takes 1 ore from slot 0"); + require(plan.fluids().size() == 1, "one fluid take"); + var water = plan.fluids().getFirst(); + require( + water.group().equals("water") && water.tank() == 0 && water.fluid().amount() == 250, + "takes 250 mB from tank 0"); + require(plan.products().size() == 2, "two products"); + var copper = plan.products().getFirst(); + require( + copper.group().equals("output") + && copper.product() instanceof ItemProduct item + && ItemStack.matches(item.stack(), rawCopper) + && copper.at().equals(List.of(new At(0, 2))), + "2 raw copper at group slot 0"); + require(plan.energy().equals(Map.of("energy", 4_000L)), "energy " + plan.energy()); + require(start.unplanned(), "of changes nothing"); + require( + CyclePlan.of(ID, crusher, new Crusher(0, 1_000).inventory).isEmpty(), + "no ore, no plan"); + require( + CyclePlan.of(ID, crusher, new Crusher(3, 249).inventory).isEmpty(), "too little water"); - // reserve: the ore is locked and the space is held; a second one that cannot fit is refused. - var held = new Crusher(3, 1_000); - var first = CyclePlan.of(ID, crusher, held.inventory).orElseThrow(); - require(first.reserve(held.inventory), "reserve"); - require(held.items.freeToTake(0) == 2, "the ore is locked"); - require(held.fluids.plan(0).take() == 250, "the water is locked"); - require(held.items.plan(1).sameAs(new SlotPlan(0, rawCopper)), "the copper space is held"); - require( - held.items.plan(3).sameAs(new SlotPlan(0, new ItemStack(Items.GRAVEL))), - "the gravel space is held"); - var cramped = new Crusher(3, 1_000); - cramped.items.setStackInSlot(1, new ItemStack(Items.RAW_COPPER, 62)); - cramped.items.setStackInSlot(2, new ItemStack(Items.COBBLESTONE, 64)); - var one = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); - var two = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); - require(one.reserve(cramped.inventory), "the first fits"); - require(!two.reserve(cramped.inventory), "the second would overfill the output"); - require( - cramped.items.plan(0).sameAs(new SlotPlan(1, none)) - && cramped.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) - && cramped.fluids.plan(0).take() == 250, - "the first stays as it was"); + // reserve: the ore is locked and the space is held; a second one that cannot fit is + // refused. + var held = new Crusher(3, 1_000); + var first = CyclePlan.of(ID, crusher, held.inventory).orElseThrow(); + require(first.reserve(held.inventory), "reserve"); + require(held.items.freeToTake(0) == 2, "the ore is locked"); + require(held.fluids.plan(0).take() == 250, "the water is locked"); + require(held.items.plan(1).sameAs(new SlotPlan(0, rawCopper)), "the copper space is held"); + require( + held.items.plan(3).sameAs(new SlotPlan(0, new ItemStack(Items.GRAVEL))), + "the gravel space is held"); + var cramped = new Crusher(3, 1_000); + cramped.items.setStackInSlot(1, new ItemStack(Items.RAW_COPPER, 62)); + cramped.items.setStackInSlot(2, new ItemStack(Items.COBBLESTONE, 64)); + var one = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); + var two = CyclePlan.of(ID, crusher, cramped.inventory).orElseThrow(); + require(one.reserve(cramped.inventory), "the first fits"); + require(!two.reserve(cramped.inventory), "the second would overfill the output"); + require( + cramped.items.plan(0).sameAs(new SlotPlan(1, none)) + && cramped.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && cramped.fluids.plan(0).take() == 250, + "the first stays as it was"); - // finish: inputs taken, products in, every plan gone. - var run = new Crusher(3, 1_000); - var cycle = CyclePlan.of(ID, crusher, run.inventory).orElseThrow(); - require(cycle.reserve(run.inventory), "reserve before finishing"); - require(cycle.finish(run.inventory, fixed(.99F)), "finish"); - require(run.items.getStackInSlot(0).getCount() == 2, "ore 3 to 2"); - require(run.fluids.tank(0).getFluidAmount() == 750, "water down 250"); - require(ItemStack.matches(run.items.getStackInSlot(1), rawCopper), "2 raw copper out"); - require(run.unplanned(), "every plan is gone"); + // finish: inputs taken, products in, every plan gone. + var run = new Crusher(3, 1_000); + var cycle = CyclePlan.of(ID, crusher, run.inventory).orElseThrow(); + require(cycle.reserve(run.inventory), "reserve before finishing"); + require(cycle.finish(run.inventory, fixed(.99F)), "finish"); + require(run.items.getStackInSlot(0).getCount() == 2, "ore 3 to 2"); + require(run.fluids.tank(0).getFluidAmount() == 750, "water down 250"); + require(ItemStack.matches(run.items.getStackInSlot(1), rawCopper), "2 raw copper out"); + require(run.unplanned(), "every plan is gone"); - // A hit puts the byproduct in; a miss leaves its space free and no plan behind. - var hit = new Crusher(3, 1_000); - var hitPlan = CyclePlan.of(ID, crusher, hit.inventory).orElseThrow(); - hitPlan.reserve(hit.inventory); - require(hitPlan.finish(hit.inventory, fixed(0F)), "finish on a hit"); - require(hit.items.getStackInSlot(3).is(Items.GRAVEL), "gravel on a hit"); - var miss = new Crusher(3, 1_000); - var missPlan = CyclePlan.of(ID, crusher, miss.inventory).orElseThrow(); - missPlan.reserve(miss.inventory); - require(missPlan.finish(miss.inventory, fixed(.5F)), "finish on a miss"); - require(miss.items.getStackInSlot(3).isEmpty(), "no gravel on a miss"); - require(miss.items.plan(3).isNone() && miss.unplanned(), "the byproduct slot is free"); + // A hit puts the byproduct in; a miss leaves its space free and no plan behind. + var hit = new Crusher(3, 1_000); + var hitPlan = CyclePlan.of(ID, crusher, hit.inventory).orElseThrow(); + hitPlan.reserve(hit.inventory); + require(hitPlan.finish(hit.inventory, fixed(0F)), "finish on a hit"); + require(hit.items.getStackInSlot(3).is(Items.GRAVEL), "gravel on a hit"); + var miss = new Crusher(3, 1_000); + var missPlan = CyclePlan.of(ID, crusher, miss.inventory).orElseThrow(); + missPlan.reserve(miss.inventory); + require(missPlan.finish(miss.inventory, fixed(.5F)), "finish on a miss"); + require(miss.items.getStackInSlot(3).isEmpty(), "no gravel on a miss"); + require(miss.items.plan(3).isNone() && miss.unplanned(), "the byproduct slot is free"); - // The one-slot machine: its input slot is also its output slot. - var ironToGold = - MachineRecipeSmoke.recipe( + // The one-slot machine: its input slot is also its output slot. + var ironToGold = MachineRecipeSmoke.recipe( jsonOps, """ - {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, - "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} - """); - var slot = new ItemStorage(1); - var oneSlot = MachineInventory.builder().group("main", StorageGroups.items(slot, 0)).build(); - slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT)); - var swap = CyclePlan.of(ID, ironToGold, oneSlot).orElseThrow(); - require(swap.reserve(oneSlot), "reserve iron to gold"); - require(slot.plan(0).sameAs(new SlotPlan(1, new ItemStack(Items.GOLD_INGOT))), "one plan"); - require(swap.finish(oneSlot, fixed(0F)), "finish iron to gold"); - require( - ItemStack.matches(slot.getStackInSlot(0), new ItemStack(Items.GOLD_INGOT)) - && slot.plan(0).isNone(), - "1 gold is left"); + {"inputs": {"main": [{"items": {"item": "minecraft:iron_ingot"}}]}, + "outputs": {"main": [{"item": {"id": "minecraft:gold_ingot"}}]}, "time": 10} + """); + var slot = new ItemStorage(1); + var oneSlot = MachineInventory.builder().group("main", StorageGroups.items(slot, 0)) + .build(); + slot.setStackInSlot(0, new ItemStack(Items.IRON_INGOT)); + var swap = CyclePlan.of(ID, ironToGold, oneSlot).orElseThrow(); + require(swap.reserve(oneSlot), "reserve iron to gold"); + require(slot.plan(0).sameAs(new SlotPlan(1, new ItemStack(Items.GOLD_INGOT))), "one plan"); + require(swap.finish(oneSlot, fixed(0F)), "finish iron to gold"); + require( + ItemStack.matches(slot.getStackInSlot(0), new ItemStack(Items.GOLD_INGOT)) + && slot.plan(0).isNone(), + "1 gold is left"); - // A failed finish changes nothing: the plan stays and nothing is inserted. - var emptied = new Crusher(3, 1_000); - var emptiedPlan = CyclePlan.of(ID, crusher, emptied.inventory).orElseThrow(); - emptiedPlan.reserve(emptied.inventory); - emptied.items.setStackInSlot(0, none); - require(!emptiedPlan.finish(emptied.inventory, fixed(0F)), "no ore, no finish"); - require(emptied.fluids.tank(0).getFluidAmount() == 1_000, "water stays"); - require(emptied.items.getStackInSlot(1).isEmpty(), "nothing inserted"); - require( - emptied.items.plan(0).sameAs(new SlotPlan(1, none)) - && emptied.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) - && emptied.fluids.plan(0).take() == 250, - "the plan stays in place"); - var dirt = new Crusher(3, 1_000); - var dirtPlan = CyclePlan.of(ID, crusher, dirt.inventory).orElseThrow(); - dirtPlan.reserve(dirt.inventory); - dirt.items.setStackInSlot(0, new ItemStack(Items.DIRT, 3)); - require(!dirtPlan.finish(dirt.inventory, fixed(0F)), "dirt is not ore"); - require( - ItemStack.matches(dirt.items.getStackInSlot(0), new ItemStack(Items.DIRT, 3)), - "the dirt is untouched"); - require(dirt.items.getStackInSlot(1).isEmpty(), "nothing inserted"); - require(dirt.items.plan(0).sameAs(new SlotPlan(1, none)), "the plan stays on the slot"); + // A failed finish changes nothing: the plan stays and nothing is inserted. + var emptied = new Crusher(3, 1_000); + var emptiedPlan = CyclePlan.of(ID, crusher, emptied.inventory).orElseThrow(); + emptiedPlan.reserve(emptied.inventory); + emptied.items.setStackInSlot(0, none); + require(!emptiedPlan.finish(emptied.inventory, fixed(0F)), "no ore, no finish"); + require(emptied.fluids.tank(0).getFluidAmount() == 1_000, "water stays"); + require(emptied.items.getStackInSlot(1).isEmpty(), "nothing inserted"); + require( + emptied.items.plan(0).sameAs(new SlotPlan(1, none)) + && emptied.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && emptied.fluids.plan(0).take() == 250, + "the plan stays in place"); + var dirt = new Crusher(3, 1_000); + var dirtPlan = CyclePlan.of(ID, crusher, dirt.inventory).orElseThrow(); + dirtPlan.reserve(dirt.inventory); + dirt.items.setStackInSlot(0, new ItemStack(Items.DIRT, 3)); + require(!dirtPlan.finish(dirt.inventory, fixed(0F)), "dirt is not ore"); + require( + ItemStack.matches(dirt.items.getStackInSlot(0), new ItemStack(Items.DIRT, 3)), + "the dirt is untouched"); + require(dirt.items.getStackInSlot(1).isEmpty(), "nothing inserted"); + require(dirt.items.plan(0).sameAs(new SlotPlan(1, none)), "the plan stays on the slot"); - // A product the pre-check lets through but the slot refuses fails inside the transaction, - // after the inputs were taken and part of the products went in; all of it is undone. - var limited = new Crusher(3, 1_000); - var limitedPlan = CyclePlan.of(ID, crusher, limited.inventory).orElseThrow(); - limitedPlan.reserve(limited.inventory); - limited.items.limit(1, 1); - require(!limitedPlan.finish(limited.inventory, fixed(.99F)), "the slot takes 1 of 2"); - require( - limited.items.getStackInSlot(0).getCount() == 3 - && limited.fluids.tank(0).getFluidAmount() == 1_000 - && limited.items.getStackInSlot(1).isEmpty(), - "the failed transaction is undone"); - require( - limited.items.plan(0).sameAs(new SlotPlan(1, none)) - && limited.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) - && limited.fluids.plan(0).take() == 250, - "the plans are back"); - limited.items.limit(Item.ABSOLUTE_MAX_STACK_SIZE, 1); - require(limitedPlan.finish(limited.inventory, fixed(.99F)), "finishes with room"); - require(ItemStack.matches(limited.items.getStackInSlot(1), rawCopper), "dust made"); + // A product the pre-check lets through but the slot refuses fails inside the transaction, + // after the inputs were taken and part of the products went in; all of it is undone. + var limited = new Crusher(3, 1_000); + var limitedPlan = CyclePlan.of(ID, crusher, limited.inventory).orElseThrow(); + limitedPlan.reserve(limited.inventory); + limited.items.limit(1, 1); + require(!limitedPlan.finish(limited.inventory, fixed(.99F)), "the slot takes 1 of 2"); + require( + limited.items.getStackInSlot(0).getCount() == 3 + && limited.fluids.tank(0).getFluidAmount() == 1_000 + && limited.items.getStackInSlot(1).isEmpty(), + "the failed transaction is undone"); + require( + limited.items.plan(0).sameAs(new SlotPlan(1, none)) + && limited.items.plan(1).sameAs(new SlotPlan(0, rawCopper)) + && limited.fluids.plan(0).take() == 250, + "the plans are back"); + limited.items.limit(Item.ABSOLUTE_MAX_STACK_SIZE, 1); + require(limitedPlan.finish(limited.inventory, fixed(.99F)), "finishes with room"); + require(ItemStack.matches(limited.items.getStackInSlot(1), rawCopper), "dust made"); - // The same with a fluid product: the drained input tank comes back and its plan is restored. - var lavaMaker = - MachineRecipeSmoke.recipe( + // The same with a fluid product: the drained input tank comes back and its plan is + // restored. + var lavaMaker = MachineRecipeSmoke.recipe( jsonOps, """ - {"inputs": {"water": [{"fluids": "minecraft:water", "amount": 250}]}, - "outputs": {"out": [{"fluid": {"id": "minecraft:lava", "amount": 500}}]}, "time": 10} - """); - var tanks = new FluidStorage(); - tanks.addTank(4_000, Role.INPUT); - tanks.addTank(4_000, Role.OUTPUT); - tanks.tank(0).fill(new FluidStack(Fluids.WATER, 1_000), FluidAction.EXECUTE); - var tankInventory = - MachineInventory.builder() + {"inputs": {"water": [{"fluids": "minecraft:water", "amount": 250}]}, + "outputs": {"out": [{"fluid": {"id": "minecraft:lava", "amount": 500}}]}, "time": 10} + """); + var tanks = new FluidStorage(); + tanks.addTank(4_000, Role.INPUT); + tanks.addTank(4_000, Role.OUTPUT); + tanks.tank(0).fill(new FluidStack(Fluids.WATER, 1_000), FluidAction.EXECUTE); + var tankInventory = MachineInventory.builder() .group("water", StorageGroups.fluids(tanks, 0)) .group("out", StorageGroups.fluids(tanks, 1)) .build(); - var lava = CyclePlan.of(ID, lavaMaker, tankInventory).orElseThrow(); - require(lava.reserve(tankInventory), "reserve the lava cycle"); - // The pre-check ignores capacity, so only the transaction notices the tank has shrunk. - tanks.tank(1).setCapacity(100); - require(!lava.finish(tankInventory, fixed(0F)), "the output tank takes 100 of 500"); - require( - tanks.tank(0).getFluidAmount() == 1_000 && tanks.tank(1).isEmpty(), - "the fluid transaction is undone"); - require( - tanks.plan(0).take() == 250 && tanks.plan(1).add().amount() == 500, - "the tank plans are back: " + tanks.plan(0) + " " + tanks.plan(1)); - tanks.tank(1).setCapacity(4_000); - require(lava.finish(tankInventory, fixed(0F)), "finishes with room"); - require( - tanks.tank(0).getFluidAmount() == 750 && tanks.tank(1).getFluidAmount() == 500, - "water down, lava made"); - require(tanks.plan(0).isNone() && tanks.plan(1).isNone(), "tank plans gone"); + var lava = CyclePlan.of(ID, lavaMaker, tankInventory).orElseThrow(); + require(lava.reserve(tankInventory), "reserve the lava cycle"); + // The pre-check ignores capacity, so only the transaction notices the tank has shrunk. + tanks.tank(1).setCapacity(100); + require(!lava.finish(tankInventory, fixed(0F)), "the output tank takes 100 of 500"); + require( + tanks.tank(0).getFluidAmount() == 1_000 && tanks.tank(1).isEmpty(), + "the fluid transaction is undone"); + require( + tanks.plan(0).take() == 250 && tanks.plan(1).add().amount() == 500, + "the tank plans are back: " + tanks.plan(0) + " " + tanks.plan(1)); + tanks.tank(1).setCapacity(4_000); + require(lava.finish(tankInventory, fixed(0F)), "finishes with room"); + require( + tanks.tank(0).getFluidAmount() == 750 && tanks.tank(1).getFluidAmount() == 500, + "water down, lava made"); + require(tanks.plan(0).isNone() && tanks.plan(1).isNone(), "tank plans gone"); - // Only a hit needs its space: a byproduct slot filled mid-cycle blocks a hit, not a miss. - var jammed = new Crusher(3, 1_000); - var jammedPlan = CyclePlan.of(ID, crusher, jammed.inventory).orElseThrow(); - jammedPlan.reserve(jammed.inventory); - jammed.items.setStackInSlot(3, new ItemStack(Items.COBBLESTONE, 64)); - require(!jammedPlan.finish(jammed.inventory, fixed(0F)), "a hit has no room"); - require( - jammed.items.getStackInSlot(0).getCount() == 3 - && jammed.items.getStackInSlot(1).isEmpty() - && jammed.items.plan(0).sameAs(new SlotPlan(1, none)), - "a blocked finish changes nothing"); - require(jammedPlan.finish(jammed.inventory, fixed(.5F)), "a miss needs no room"); - require(jammed.items.getStackInSlot(3).getCount() == 64, "the cobblestone stays"); - require(ItemStack.matches(jammed.items.getStackInSlot(1), rawCopper), "the dust is made"); + // Only a hit needs its space: a byproduct slot filled mid-cycle blocks a hit, not a miss. + var jammed = new Crusher(3, 1_000); + var jammedPlan = CyclePlan.of(ID, crusher, jammed.inventory).orElseThrow(); + jammedPlan.reserve(jammed.inventory); + jammed.items.setStackInSlot(3, new ItemStack(Items.COBBLESTONE, 64)); + require(!jammedPlan.finish(jammed.inventory, fixed(0F)), "a hit has no room"); + require( + jammed.items.getStackInSlot(0).getCount() == 3 + && jammed.items.getStackInSlot(1).isEmpty() + && jammed.items.plan(0).sameAs(new SlotPlan(1, none)), + "a blocked finish changes nothing"); + require(jammedPlan.finish(jammed.inventory, fixed(.5F)), "a miss needs no room"); + require(jammed.items.getStackInSlot(3).getCount() == 64, "the cobblestone stays"); + require(ItemStack.matches(jammed.items.getStackInSlot(1), rawCopper), "the dust is made"); - // release: this plan goes, another lane's plan on the same slot stays. - var shared = new Crusher(3, 1_000); - var other = new SlotPlan(1, none); - shared.items.setPlan(0, other); - var mine = CyclePlan.of(ID, crusher, shared.inventory).orElseThrow(); - mine.reserve(shared.inventory); - mine.release(shared.inventory); - require(shared.items.plan(0).sameAs(other), "the other plan stays"); - require(shared.items.plan(1).isNone() && shared.items.plan(3).isNone(), "outputs released"); - require(shared.fluids.plan(0).isNone(), "tank released"); - var alone = new Crusher(3, 1_000); - var lone = CyclePlan.of(ID, crusher, alone.inventory).orElseThrow(); - lone.reserve(alone.inventory); - lone.release(alone.inventory); - require(alone.unplanned(), "all plans are NONE"); + // release: this plan goes, another lane's plan on the same slot stays. + var shared = new Crusher(3, 1_000); + var other = new SlotPlan(1, none); + shared.items.setPlan(0, other); + var mine = CyclePlan.of(ID, crusher, shared.inventory).orElseThrow(); + mine.reserve(shared.inventory); + mine.release(shared.inventory); + require(shared.items.plan(0).sameAs(other), "the other plan stays"); + require(shared.items.plan(1).isNone() && shared.items.plan(3).isNone(), "outputs released"); + require(shared.fluids.plan(0).isNone(), "tank released"); + var alone = new Crusher(3, 1_000); + var lone = CyclePlan.of(ID, crusher, alone.inventory).orElseThrow(); + lone.reserve(alone.inventory); + lone.release(alone.inventory); + require(alone.unplanned(), "all plans are NONE"); - // Round trip: the same plan comes back from NBT and restores the same reservations. - var nbt = access.createSerializationContext(NbtOps.INSTANCE); - var saved = new Crusher(3, 1_000); - var savedPlan = CyclePlan.of(ID, crusher, saved.inventory).orElseThrow(); - savedPlan.reserve(saved.inventory); - var tag = (CompoundTag) CyclePlan.CODEC.encodeStart(nbt, savedPlan).getOrThrow(); - var loaded = CyclePlan.CODEC.parse(nbt, tag).getOrThrow(); - require( - loaded.recipe().equals(ID) - && loaded.time() == 200 - && loaded.energy().equals(savedPlan.energy()) - && loaded.products().size() == 2, - "decoded plan"); - var fresh = new Crusher(3, 1_000); - require(loaded.restore(fresh.inventory), "restore on fresh storages"); - for (int index = 0; index < 4; index++) - require(fresh.items.plan(index).sameAs(saved.items.plan(index)), "plan " + index); - require(fresh.fluids.plan(0).sameAs(saved.fluids.plan(0)), "tank plan"); + // Round trip: the same plan comes back from NBT and restores the same reservations. + var nbt = access.createSerializationContext(NbtOps.INSTANCE); + var saved = new Crusher(3, 1_000); + var savedPlan = CyclePlan.of(ID, crusher, saved.inventory).orElseThrow(); + savedPlan.reserve(saved.inventory); + var tag = (CompoundTag) CyclePlan.CODEC.encodeStart(nbt, savedPlan).getOrThrow(); + var loaded = CyclePlan.CODEC.parse(nbt, tag).getOrThrow(); + require( + loaded.recipe().equals(ID) + && loaded.time() == 200 + && loaded.energy().equals(savedPlan.energy()) + && loaded.products().size() == 2, + "decoded plan"); + var fresh = new Crusher(3, 1_000); + require(loaded.restore(fresh.inventory), "restore on fresh storages"); + for (int index = 0; index < 4; index++) + require(fresh.items.plan(index).sameAs(saved.items.plan(index)), "plan " + index); + require(fresh.fluids.plan(0).sameAs(saved.fluids.plan(0)), "tank plan"); - // restore refuses what no longer holds, and leaves no plan behind. - var oreTake = new ItemStack(Items.COPPER_ORE); - var cases = - Map.of( + // restore refuses what no longer holds, and leaves no plan behind. + var oreTake = new ItemStack(Items.COPPER_ORE); + var cases = Map.of( "slot past the end", withItems(savedPlan, List.of(new ItemTake("input", 1, oreTake))), "missing group", withItems(savedPlan, List.of(new ItemTake("nothing", 0, oreTake))), "fewer items", - withItems( - savedPlan, - List.of(new ItemTake("input", 0, new ItemStack(Items.COPPER_ORE, 4)))), + withItems( + savedPlan, + List.of(new ItemTake("input", 0, new ItemStack(Items.COPPER_ORE, 4)))), "different item", - withItems( - savedPlan, List.of(new ItemTake("input", 0, new ItemStack(Items.IRON_ORE)))), + withItems( + savedPlan, List.of(new ItemTake("input", 0, new ItemStack(Items.IRON_ORE)))), "product past the end", - new CyclePlan( - ID, - 200, - savedPlan.items(), - savedPlan.fluids(), - List.of(new Placed("output", copper.product(), List.of(new At(9, 2)))), - Map.of()), + new CyclePlan( + ID, + 200, + savedPlan.items(), + savedPlan.fluids(), + List.of(new Placed("output", copper.product(), List.of(new At(9, 2)))), + Map.of()), "fewer mB", - new CyclePlan( - ID, - 200, - savedPlan.items(), - List.of(new FluidTake("water", 0, water.fluid().withAmount(2_000))), - savedPlan.products(), - Map.of())); - for (var entry : cases.entrySet()) { - var target = new Crusher(3, 1_000); - require(!entry.getValue().restore(target.inventory), entry.getKey() + " is refused"); - require(target.unplanned(), entry.getKey() + " leaves no plan"); - } + new CyclePlan( + ID, + 200, + savedPlan.items(), + List.of(new FluidTake("water", 0, water.fluid().withAmount(2_000))), + savedPlan.products(), + Map.of())); + for (var entry : cases.entrySet()) { + var target = new Crusher(3, 1_000); + require(!entry.getValue().restore(target.inventory), entry.getKey() + " is refused"); + require(target.unplanned(), entry.getKey() + " leaves no plan"); + } - // An unknown item is a decode error, not an exception. - var broken = tag.copy(); - broken - .getList("items", 10) - .getCompound(0) - .getCompound("stack") - .putString("id", "missing_mod:thing"); - require(CyclePlan.CODEC.parse(nbt, broken).error().isPresent(), "unknown item errors"); - } + // An unknown item is a decode error, not an exception. + var broken = tag.copy(); + broken + .getList("items", 10) + .getCompound(0) + .getCompound("stack") + .putString("id", "missing_mod:thing"); + require(CyclePlan.CODEC.parse(nbt, broken).error().isPresent(), "unknown item errors"); + } - private static CyclePlan withItems(CyclePlan plan, List items) { - return new CyclePlan( - plan.recipe(), plan.time(), items, plan.fluids(), plan.products(), plan.energy()); - } + private static CyclePlan withItems(CyclePlan plan, List items) { + return new CyclePlan( + plan.recipe(), plan.time(), items, plan.fluids(), plan.products(), plan.energy()); + } - /** A random whose nextFloat() is always {@code value}: 0 always hits a chance, 0.99 misses. */ - private static RandomSource fixed(float value) { - return new LegacyRandomSource(0) { - @Override - public float nextFloat() { - return value; - } - }; - } + /** A random whose nextFloat() is always {@code value}: 0 always hits a chance, 0.99 misses. */ + private static RandomSource fixed(float value) { + return new LegacyRandomSource(0) { + @Override + public float nextFloat() { + return value; + } + }; + } - private static void require(boolean condition, String what) { - if (!condition) throw new AssertionError("Cycle plan smoke failed: " + what); - } + private static void require(boolean condition, String what) { + if (!condition) + throw new AssertionError("Cycle plan smoke failed: " + what); + } } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReusableInputSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReusableInputSmoke.java new file mode 100644 index 00000000..5d4d68d1 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ReusableInputSmoke.java @@ -0,0 +1,194 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import io.netty.buffer.Unpooled; + +import net.minecraft.core.HolderSet; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +import zone.moddev.mc.lib.api.capabilities.FluidStorage; +import zone.moddev.mc.lib.api.capabilities.ItemStorage; +import zone.moddev.mc.lib.api.capabilities.Role; +import zone.moddev.mc.lib.machine.ingredient.FluidAmount; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.inventory.MachineInventory; +import zone.moddev.mc.lib.machine.neoforge.StorageGroups; +import zone.moddev.mc.lib.machine.recipe.CyclePlan; +import zone.moddev.mc.lib.machine.recipe.MachineProduct; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.RecipeMatcher; + +/** Real item/fluid catalysts, including allocation, capacity, persistence and recipe transport. */ +final class ReusableInputSmoke { + private static final ResourceLocation ID = ResourceLocation.parse("example:reusable"); + + private ReusableInputSmoke() { + } + + private static final class Storage { + final ItemStorage items = new ItemStorage(2); + final FluidStorage fluids = new FluidStorage(); + final MachineInventory inventory; + + Storage() { + items.setStackInSlot(0, new ItemStack(Items.IRON_INGOT)); + items.setStackInSlot(1, new ItemStack(Items.GOLD_INGOT)); + fluids.addTank(1000, Role.INPUT); + fluids.tank(0).fill(new FluidStack(Fluids.WATER, 1000), FluidAction.EXECUTE); + inventory = MachineInventory.builder() + .group("items", StorageGroups.items(items, 0, 1)) + .group("water", StorageGroups.fluids(fluids, 0)).build(); + } + + boolean unplanned() { + return items.plan(0).isNone() && items.plan(1).isNone() && fluids.plan(0).isNone(); + } + } + + static void check(ServerLevel level) { + var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()); + for (String resource : List.of("\"items\":{\"item\":\"minecraft:iron_ingot\"}", + "\"fluids\":\"minecraft:water\",\"amount\":1000")) { + for (boolean consumed : List.of(false, true)) { + var json = JsonParser + .parseString("{" + resource + ",\"consumed\":" + consumed + "}"); + var part = MachineIngredient.CODEC.parse(ops, json).getOrThrow(); + require(part.consumed() == consumed, "JSON consumption flag"); + var encoded = MachineIngredient.CODEC.encodeStart(ops, part).getOrThrow(); + require( + MachineIngredient.CODEC.parse(ops, encoded).getOrThrow().consumed() == consumed, + "JSON round trip"); + var wire = new RegistryFriendlyByteBuf(Unpooled.buffer(), level.registryAccess()); + try { + MachineIngredient.STREAM_CODEC.encode(wire, part); + var decoded = MachineIngredient.STREAM_CODEC.decode(wire); + require(decoded.consumed() == consumed && decoded.amount() == part.amount(), + "wire round trip"); + require(!wire.isReadable(), "wire consumed exactly one ingredient"); + } finally { + wire.release(); + } + } + require(MachineIngredient.CODEC.parse(ops, JsonParser.parseString("{" + resource + "}")) + .getOrThrow().consumed(), "old recipes default to consumed"); + } + require(MachineIngredient.CODEC.parse(ops, + JsonParser.parseString("{\"energy\":20,\"consumed\":false}")).error().isPresent(), + "reusable energy rejected"); + require(MachineIngredient.CODEC.parse(ops, + JsonParser + .parseString("{\"items\":{\"item\":\"minecraft:iron_ingot\"},\"consumed\":\"no\"}")) + .error().isPresent(), "malformed consumption flag rejected"); + + var water = HolderSet.direct(Fluids.WATER.builtInRegistryHolder()); + var mixed = new MachineRecipe(Map.of( + "items", List.of( + new MachineIngredient.ItemPart(Ingredient.of(Items.IRON_INGOT, Items.GOLD_INGOT), 1, + false), + new MachineIngredient.ItemPart(Ingredient.of(Items.IRON_INGOT), 1)), + "water", List.of(new MachineIngredient.FluidPart(water, 750, false), + new MachineIngredient.FluidPart(water, 250))), + Map.of("items", + List.of(new MachineProduct.ItemProduct(new ItemStack(Items.GOLD_INGOT), 1))), + 10); + var storage = new Storage(); + require(RecipeMatcher.check(mixed, storage.inventory).ok(), "mixed recipe matches"); + var plan = CyclePlan.of(ID, mixed, storage.inventory).orElseThrow(); + require(plan.items().stream().filter(take -> take.retained() > 0) + .allMatch(take -> take.stack().is(Items.GOLD_INGOT)), "broad catalyst moves off iron"); + require(plan.fluids().getFirst().retained() == 750, "mixed fluid shares tracked"); + require(plan.reserve(storage.inventory), "mixed inputs reserve"); + require(storage.items.freeToTake(0) == 0 && storage.items.freeToTake(1) == 0, + "both consumed and retained items locked"); + require(storage.fluids.drain(1000, FluidAction.EXECUTE).isEmpty(), + "all required fluid locked"); + require(CyclePlan.of(ID, mixed, storage.inventory).isEmpty(), + "another cycle cannot reuse locks"); + + var nbt = RegistryOps.create(NbtOps.INSTANCE, level.registryAccess()); + var saved = CyclePlan.CODEC.encodeStart(nbt, plan).getOrThrow(); + var restored = CyclePlan.CODEC.parse(nbt, saved).getOrThrow(); + var loaded = new Storage(); + require(restored.restore(loaded.inventory), "saved reservations restored"); + require(restored.finish(loaded.inventory, RandomSource.create(0)), + "restored cycle completes"); + require(loaded.fluids.tank(0).getFluidAmount() == 750, "only 250 mB consumed"); + require(loaded.items.getStackInSlot(0).isEmpty() + && loaded.items.getStackInSlot(1).is(Items.GOLD_INGOT) + && loaded.items.getStackInSlot(1).getCount() == 2, + "gold catalyst kept and product made"); + require(loaded.unplanned(), "finish releases every reservation"); + var missing = new Storage(); + missing.items.setStackInSlot(1, ItemStack.EMPTY); + require(!restored.restore(missing.inventory) && missing.unplanned(), + "missing catalyst rejects restore"); + plan.release(storage.inventory); + require(storage.unplanned() && storage.items.freeToTake(1) == 1, + "pause/release unlocks catalysts"); + require(RecipeMatcher.consume(mixed, storage.inventory), "immediate consumption succeeds"); + require( + storage.items.getStackInSlot(0).isEmpty() + && storage.items.getStackInSlot(1).is(Items.GOLD_INGOT) + && storage.fluids.tank(0).getFluidAmount() == 750, + "immediate consumption preserves catalysts"); + + var reusable = MachineRecipeSmoke.recipe(ops, + """ + {"inputs":{"water":[{"fluids":"minecraft:water","amount":1000,"consumed":false}], + "items":[{"items":{"item":"minecraft:gold_ingot"},"consumed":false}]},"time":10} + """); + var full = new Storage(); + for (int i = 0; i < 3; i++) { + var cycle = CyclePlan.of(ID, reusable, full.inventory).orElseThrow(); + require( + cycle.reserve(full.inventory) + && cycle.finish(full.inventory, RandomSource.create(0)), + "full tank catalyst cycles repeatedly"); + require(full.fluids.tank(0).getFluidAmount() == 1000 + && full.items.getStackInSlot(1).getCount() == 1 && full.unplanned(), + "no loss or duplication"); + } + // Retained water cannot be replaced by lava or make room for additional water. + for (var fluid : List.of(Fluids.WATER, Fluids.LAVA)) { + var product = new MachineProduct.FluidProduct( + new FluidAmount(fluid.builtInRegistryHolder(), + net.minecraft.core.component.DataComponentPatch.EMPTY, 1), + 1); + var blocked = new MachineRecipe(reusable.inputs(), Map.of("water", List.of(product)), + 10); + require(!RecipeMatcher.check(blocked, full.inventory).ok() + && CyclePlan.of(ID, blocked, full.inventory).isEmpty(), + "retained fluid occupies capacity"); + } + full.items.setStackInSlot(0, new ItemStack(Items.GOLD_INGOT, 64)); + full.items.setStackInSlot(1, new ItemStack(Items.GOLD_INGOT, 64)); + var blockedItems = new MachineRecipe(reusable.inputs(), Map.of("items", + List.of(new MachineProduct.ItemProduct(new ItemStack(Items.GOLD_INGOT), 1))), 10); + require(!RecipeMatcher.check(blockedItems, full.inventory).ok() + && CyclePlan.of(ID, blockedItems, full.inventory).isEmpty(), + "retained items occupy capacity"); + } + + private static void require(boolean condition, String message) { + if (!condition) + throw new AssertionError("Reusable inputs: " + message); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayoutTest.java b/src/test/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayoutTest.java new file mode 100644 index 00000000..d717570a --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/client/jei/MachineRecipeLayoutTest.java @@ -0,0 +1,73 @@ +package zone.moddev.mc.lib.client.jei; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MachineRecipeLayoutTest { + @Test + void ghostInputsAreSeparatedFromConsumedIngredients() { + var stone = new MachineRecipeLayout(2, 1, 1); + assertTrue(stone.separator()); + assertEquals(48, stone.inputX()); + assertEquals(70, stone.arrowX()); + assertEquals(98, stone.outputX()); + assertEquals(116, stone.width()); + assertEquals(1, stone.rows()); + } + + @Test + void categoriesWithoutConsumedInputsDoNotLeaveAnEmptyIngredientSection() { + var cobblestone = new MachineRecipeLayout(3, 0, 1); + assertFalse(cobblestone.separator()); + assertEquals(58, cobblestone.arrowX()); + assertEquals(104, cobblestone.width()); + var ordinary = new MachineRecipeLayout(0, 2, 1); + assertFalse(ordinary.separator()); + assertEquals(0, ordinary.inputX()); + assertEquals(86, ordinary.width()); + } + + @Test + void categoryReservesSharedColumnsEvenWhenARecipeHasNoConsumedInputs() { + var cobblestone = new MachineRecipeLayout(3, 0, 1); + var stone = new MachineRecipeLayout(3, 1, 1); + var category = cobblestone.merge(stone); + assertEquals(stone, category); + assertEquals(category, stone.merge(cobblestone)); + assertEquals(66, category.inputX()); + assertEquals(88, category.arrowX()); + assertEquals(116, category.outputX()); + assertFalse(cobblestone.separator()); + assertTrue(stone.separator()); + + // Different recipes may supply the widest section on each side of the arrow. + var mixed = new MachineRecipeLayout(3, 0, 1) + .merge(new MachineRecipeLayout(0, 3, 5)); + assertEquals(new MachineRecipeLayout(3, 3, 5), mixed); + assertEquals(206, mixed.width()); + assertEquals(2, mixed.rows()); + } + + @Test + void eachSectionWrapsAfterThreeSlots() { + var multiple = new MachineRecipeLayout(4, 7, 5); + assertEquals(3, multiple.rows()); + assertEquals(66, multiple.inputX()); + assertEquals(152, multiple.outputX()); + assertEquals(206, multiple.width()); + assertEquals(1, new MachineRecipeLayout(0, 0, 0).rows()); + } + + @Test + void fluidScalesUseWholeBucketsAndDoNotOverflow() { + for (int amount : new int[]{1, 100, 250, 999, 1000}) + assertEquals(1000, MachineRecipeLayout.fluidCapacity(amount)); + for (int amount : new int[]{1001, 1250, 2000}) + assertEquals(2000, MachineRecipeLayout.fluidCapacity(amount)); + assertEquals(3000, MachineRecipeLayout.fluidCapacity(2001)); + assertEquals(2147484000L, MachineRecipeLayout.fluidCapacity(Integer.MAX_VALUE)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/ElementBehaviorsTest.java b/src/test/java/zone/moddev/mc/lib/gui/ElementBehaviorsTest.java index 34da7a31..bf41e804 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ElementBehaviorsTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ElementBehaviorsTest.java @@ -43,6 +43,48 @@ /** Mod types outside the implementation package exercise the public contracts. */ class ElementBehaviorsTest { + @Test + void transientChooserOwnsHitsOutsideItsSlotAndConsumesDismissal() { + var slot = new ModElement(Common.empty()); + var underlying = new ModElement(Common.empty()); + int[] clicks = {0, 0}; + boolean[] open = {true}; + var chooser = new ElementBehavior() { + public Optional overlayBounds() { + return open[0] ? Optional.of(new Rect(0, 20, 58, 22)) : Optional.empty(); + } + public void dismissOverlay() { + open[0] = false; + } + public Click mouseClicked(double x, double y, int button) { + clicks[0]++; + return Click.CONSUME; + } + }; + var other = new ElementBehavior() { + public Click mouseClicked(double x, double y, int button) { + clicks[1]++; + return Click.CONSUME; + } + }; + var input = new ElementInput<>(List.of(slot, underlying), + e -> e == slot ? chooser : other, e -> true, + e -> e == slot ? new Rect(0, 0, 18, 18) : new Rect(0, 20, 100, 40)); + input.update(); + assertEquals(slot, input.hit(30, 25)); + assertTrue(input.click(30, 25, 0)); + assertEquals(1, clicks[0]); + assertEquals(0, clicks[1]); + assertTrue(input.click(90, 25, 0)); + assertEquals(0, clicks[1]); + assertNull(input.overlayOwner()); + assertTrue(input.click(90, 25, 0)); + assertEquals(1, clicks[1]); + open[0] = true; + assertTrue(input.keyPressed(256, 0, 0)); + assertNull(input.overlayOwner()); + } + interface Family extends Element { } diff --git a/src/test/java/zone/moddev/mc/lib/gui/ElementSyncTest.java b/src/test/java/zone/moddev/mc/lib/gui/ElementSyncTest.java index b4876d34..843c429a 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ElementSyncTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ElementSyncTest.java @@ -84,6 +84,24 @@ private static ElementStates store(List elements, Runnable layout) { return new ElementStates(elements, RegistryAccess.EMPTY, layout); } + @Test + void inlineSidePolicyIsBoundedAndDefaultsClosedUntilSnapshot() { + var element = new zone.moddev.mc.lib.gui.model.elements.SideConfigElement(Common.empty(), + List.of("food", "energy")); + var field = zone.moddev.mc.lib.gui.model.elements.SideConfigElement.DISABLED_FACES; + assertEquals(List.of(63, 63), field.initial(element)); + var policy = List.of(1 << zone.moddev.mc.lib.api.capabilities.RelativeFace.RIGHT.ordinal(), + 0); + var encoded = field.encode(RegistryAccess.EMPTY, element, policy); + assertEquals(policy, field.decode(RegistryAccess.EMPTY, element, encoded)); + assertThrows(IllegalArgumentException.class, + () -> field.encode(RegistryAccess.EMPTY, element, List.of(0))); + assertThrows(IllegalArgumentException.class, + () -> field.encode(RegistryAccess.EMPTY, element, List.of(0, 64))); + assertThrows(IllegalArgumentException.class, () -> field.decode(RegistryAccess.EMPTY, + element, new ElementStateValue(new byte[]{2, 0, 64}))); + } + @Test void menusAndEqualElementsHaveIndependentStateAndBatchLayoutNotification() { var first = element(COUNT); diff --git a/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java b/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java new file mode 100644 index 00000000..1ae70b6f --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java @@ -0,0 +1,73 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; + +import java.nio.charset.StandardCharsets; + +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.gui.load.ScreenReader; +import zone.moddev.mc.lib.gui.model.elements.RecipeSelectorElement; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; + +class RecipeSelectorTest { + private RecipeSelectorElement read(String attributes) throws Exception { + String xml = "" + + ""; + return (RecipeSelectorElement) ScreenReader.read(ResourceLocation.parse("test:selector"), + "selector.xml", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).body() + .getFirst(); + } + + @Test + void selectorOwnsStateAndActionsAndValidatesItsProcessorBinding() throws Exception { + var element = read("processor-key=\"stew\""); + org.junit.jupiter.api.Assertions.assertArrayEquals(new int[]{18, 18}, + element.measure(text -> 0)); + element.validateBindings(Map.of("processors", Set.of("stew"))); + assertThrows(IllegalArgumentException.class, + () -> element.validateBindings(Map.of("processors", Set.of("other")))); + assertEquals(java.util.List.of(RecipeSelectorElement.EDIT), element.actions()); + assertEquals(java.util.List.of(RecipeSelectorElement.VIEW), element.synchronizedValues()); + for (String attributes : java.util.List.of("", "processor-key=\"\"", + "processor-key=\"stew\" value=\"#getValue\"", + "processor-key=\"stew\" on-click=\"test:click\"")) + assertThrows(IllegalArgumentException.class, () -> read(attributes)); + } + + @Test + void oldSaveAndExplicitlyClearedSelectionHaveDifferentPersistentStates() { + var legacy = RecipeProcessor.State.CODEC + .parse(JsonOps.INSTANCE, JsonParser.parseString("{}")).getOrThrow(); + assertTrue(legacy.selection().isEmpty()); + var cleared = RecipeProcessor.State.CODEC.parse(JsonOps.INSTANCE, + JsonParser.parseString("{\"selection\":{}}")).getOrThrow(); + assertTrue(cleared.selection().isPresent()); + assertTrue(cleared.selection().orElseThrow().recipe().isEmpty()); + var selected = RecipeProcessor.State.CODEC.parse(JsonOps.INSTANCE, + JsonParser.parseString("{\"selection\":{\"recipe\":\"test:stone\"}}")).getOrThrow(); + assertEquals(ResourceLocation.parse("test:stone"), + selected.selection().orElseThrow().recipe().orElseThrow()); + assertFalse(selected.equals(cleared)); + for (var state : java.util.List.of(legacy, cleared, selected)) { + var encoded = RecipeProcessor.State.CODEC.encodeStart(JsonOps.INSTANCE, state) + .getOrThrow(); + assertEquals(state, + RecipeProcessor.State.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow()); + } + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineColorsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineColorsTest.java new file mode 100644 index 00000000..226e2849 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineColorsTest.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.lib.machine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.machine.definition.MachineColors; + +class MachineColorsTest { + @Test + void namesAndHexResolveToTheSameRgb() { + MachineColors.palette().forEach((name, rgb) -> { + assertEquals(rgb.intValue(), MachineColors.parse(name)); + assertEquals(rgb.intValue(), MachineColors.parse(String.format("#%06X", rgb))); + }); + assertEquals(0, MachineColors.parse("#000000")); + assertEquals(0xffffff, MachineColors.parse("#ffffff")); + assertThrows(UnsupportedOperationException.class, + () -> MachineColors.palette().put("other", 0)); + for (var value : List.of("", "#fff", "#12345678", "red", "input.primary ", "Input.Primary", + "#xyzxyz")) + assertThrows(IllegalArgumentException.class, () -> MachineColors.parse(value)); + } + + @Test + void roleFamiliesCycleWithoutDependingOnOtherResources() { + for (int i = 0; i < 12; i++) { + String variant = List.of("primary", "secondary", "tertiary").get(i % 3); + assertEquals(MachineColors.parse("input." + variant), + MachineColors.item(IoMode.INPUT, i)); + assertEquals(MachineColors.parse("output." + variant), + MachineColors.item(IoMode.OUTPUT, i)); + assertEquals(MachineColors.parse("fluid." + variant), MachineColors.fluid(i)); + assertEquals(MachineColors.parse("storage"), MachineColors.item(IoMode.BOTH, i)); + assertEquals(MachineColors.parse("manual"), MachineColors.item(IoMode.DISABLED, i)); + } + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java index 781492cb..c57aaf36 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -15,6 +15,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import net.minecraft.resources.ResourceLocation; @@ -22,6 +23,7 @@ import org.junit.jupiter.api.io.TempDir; import zone.moddev.mc.lib.common.loading.DefinitionLoader.SourceRoot; +import zone.moddev.mc.lib.machine.definition.MachineDefinition; import zone.moddev.mc.lib.machine.definition.MachineDefinitions; import zone.moddev.mc.lib.machine.recipe.generation.GeneratorProperties; @@ -46,6 +48,286 @@ private String crusher() throws Exception { .readString(Path.of("src/companion/resources/mmdlib/machine/simple_crusher.xml")); } + @Test + void recipePresentationOnlyMarksGhostInputsSharedByItsMachines() throws Exception { + write(crusher()); + var real = MachineDefinitions.load(sources()).get(ResourceLocation.parse("test:crusher")); + write(crusher().replace("", + "")); + var ghost = MachineDefinitions.load(sources()).get(real.id()); + var type = ghost.recipeType().id(); + assertEquals(Set.of("pattern"), MachineDefinition.ghostInputsFor(List.of(ghost), type)); + assertEquals(Set.of(), MachineDefinition.ghostInputsFor(List.of(ghost, real), type)); + assertEquals(Set.of(), MachineDefinition.ghostInputsFor(List.of(real), type)); + assertEquals(Set.of(), MachineDefinition.ghostInputsFor(List.of(ghost), + ResourceLocation.parse("test:unknown"))); + assertEquals(Set.of(), MachineDefinition.ghostInputsFor(List.of(), type)); + } + + @Test + void ghostInputsAreDeclaredUnderItemsWithoutAllocatingRealStorage() throws Exception { + String ghost = ""; + write(crusher().replace("", "" + ghost)); + var machine = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + assertEquals(3, machine.slots().size()); + assertEquals(1, machine.ghosts().size()); + assertEquals("pattern", machine.ghosts().getFirst().id()); + assertEquals(ResourceLocation.parse("minecraft:stone"), + machine.ghosts().getFirst().defaultItem()); + var origin = new zone.moddev.mc.lib.gui.load.ScreenReader.Node("placeholder", + Map.of("generator", "mmdlib:resource-groups", "role", "input"), List.of(), "test", 1); + var generated = zone.moddev.mc.lib.machine.definition.MachineScreenGenerators + .generate(machine, origin); + var inventory = generated.stream().filter(node -> node.name().equals("inventory")) + .findFirst().orElseThrow(); + assertEquals("pattern", inventory.attributes().get("storage")); + assertEquals("ghost-slot", inventory.children().getFirst().name()); + for (String invalid : List.of(ghost.replace("role=\"input\"", "role=\"output\""), + ghost.replace("type=\"ghost\"", "type=\"phantom\""), + ghost.replace("count=\"1\"", "count=\"55\""), + ghost.replace("id=\"pattern\"", "id=\"output\""), + ghost.replace("type=\"ghost\"", "type=\"item\""))) { + write(crusher().replace("", "" + invalid)); + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())); + } + write(crusher().replace("", + "" + ghost.replace(" MachineDefinitions.load(sources())); + } + } + + @Test + void modelPresetUsesAuthoredBaseWithOrWithoutConditionalParts() throws Exception { + for (String parts : List.of("", """ + + """)) { + write(withAppearance("" + + parts + "")); + var definition = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + assertEquals(ResourceLocation.parse("test:block/casing"), definition.baseModel()); + assertTrue(definition.textures().isEmpty()); + assertTrue(definition.faceTextures().isEmpty()); + assertEquals(!parts.isEmpty(), definition.hasConditionalAppearance()); + var assets = zone.moddev.mc.lib.machine.neoforge.MachineResourcePack.build( + net.minecraft.server.packs.PackType.CLIENT_RESOURCES, + Map.of(definition.id(), definition)); + var model = generatedModel(assets, "test:models/block/crusher.json"); + if (parts.isEmpty()) { + assertEquals("test:block/casing", model.get("parent").getAsString()); + assertFalse(model.has("loader")); + assertFalse(assets + .containsKey(ResourceLocation.parse("test:models/block/crusher_base.json"))); + } else { + assertEquals("mmdlib:conditional_machine", model.get("loader").getAsString()); + assertEquals("test:block/crusher_base", model.get("parent").getAsString()); + assertEquals("test:block/crusher_base", + model.getAsJsonObject("base").get("parent").getAsString()); + assertEquals("test:block/casing", generatedModel(assets, + "test:models/block/crusher_base.json").get("parent").getAsString()); + assertEquals("test:block/full", + model.getAsJsonArray("parts").get(0).getAsJsonObject() + .getAsJsonObject("model").get("parent").getAsString()); + assertFalse(model.getAsJsonObject("textures").has("particle")); + assertTrue(model.getAsJsonArray("face_textures").isEmpty()); + } + assertEquals("test:block/crusher", generatedModel(assets, + "test:models/item/crusher.json").get("parent").getAsString()); + } + } + + @Test + void modelPresetRejectsCubeSettingsFaceTexturesAndRecursiveModels() throws Exception { + for (String appearance : List.of( + "", + "", + "", + "", + "", + """ + + + + """, + "", + "")) { + write(withAppearance(appearance)); + var error = assertThrows(IllegalArgumentException.class, + () -> MachineDefinitions.load(sources())); + assertTrue(error.getMessage().contains("crusher.xml"), error.getMessage()); + } + } + + @Test + void changingPresetDiscardsInheritedBaseAttributesButPreservesParts() throws Exception { + write( + """ + + + + """); + Files.writeString(directory.resolve("machine/base.xml"), + withAppearance( + """ + + + + """) + .replace(" + """).replace(" + + + """); + definition = MachineDefinitions.load(sources()).get(id); + assertNull(definition.baseModel()); + assertEquals("test:block/side", definition.textures().get("bottom")); + } + + private String withAppearance(String appearance) throws Exception { + String original = crusher(); + int start = original.indexOf("", start) + 2; + return original.substring(0, start) + appearance + original.substring(end); + } + + private com.google.gson.JsonObject generatedModel(Map assets, + String path) { + return com.google.gson.JsonParser + .parseString(new String(assets.get(ResourceLocation.parse(path)), + java.nio.charset.StandardCharsets.UTF_8)) + .getAsJsonObject(); + } + + @Test + void faceTextureRulesAloneSubscribeVariablesAndGenerateOrderedOverrides() throws Exception { + String original = crusher(); + write(original); + String processor = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")).processor(); + int end = original.indexOf("/>", original.indexOf("\n" + + "\n" + + "\n" + + "\n" + + "" + original.substring(end + 2); + write(xml); + var definition = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + assertTrue(definition.modelParts().isEmpty()); + assertTrue(definition.hasConditionalAppearance()); + assertEquals(3, definition.visualConditions().size()); + assertEquals(List.of("blocked", "running", "full"), + definition.faceTextures().stream().map(rule -> rule.id()).toList()); + var assets = zone.moddev.mc.lib.machine.neoforge.MachineResourcePack.build( + net.minecraft.server.packs.PackType.CLIENT_RESOURCES, + Map.of(definition.id(), definition)); + var model = com.google.gson.JsonParser.parseString(new String( + assets.get(ResourceLocation.parse("test:models/block/crusher.json")), + java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("mmdlib:conditional_machine", model.get("loader").getAsString()); + var rules = model.getAsJsonArray("face_textures"); + assertEquals("front", rules.get(0).getAsJsonObject().get("face").getAsString()); + assertEquals("test:block/blocked", model.getAsJsonObject("textures").get( + rules.get(0).getAsJsonObject().get("texture").getAsString()).getAsString()); + assertEquals("test:block/running", model.getAsJsonObject("textures").get( + rules.get(1).getAsJsonObject().get("texture").getAsString()).getAsString()); + assertEquals(ResourceLocation.parse("test:block/running_mask"), + definition.faceTextures().get(1).emissiveMask()); + assertEquals("test:block/running_mask", model.getAsJsonObject("textures").get( + rules.get(1).getAsJsonObject().get("emissive_mask").getAsString()).getAsString()); + for (String invalid : List.of(xml.replace("face=\"front\"", "face=\"north\""), + xml.replace(".running", ".typo"), xml.replace("test:block/running", "Bad:texture"))) { + write(invalid); + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())); + } + } + + @Test + void conditionalPartsValidateComponentVariablesAndPreserveTheirSource() throws Exception { + String original = crusher(); + int start = original.indexOf("", start); + String xml = original.substring(0, end) + ">\n" + + "\n" + + "" + original.substring(end + 2); + // Use the fixture's actual processor ID rather than assuming a built-in name. + write(original); + String processor = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")).processor(); + xml = xml.replace("processors.crushing", "processors." + processor); + write(xml); + var definition = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + assertEquals(1, definition.modelParts().size()); + assertEquals(ResourceLocation.parse("test:block/light"), + definition.modelParts().getFirst().model()); + assertEquals(false, + definition.visualDefaults().get("processors." + processor + ".running")); + var assets = zone.moddev.mc.lib.machine.neoforge.MachineResourcePack.build( + net.minecraft.server.packs.PackType.CLIENT_RESOURCES, + Map.of(definition.id(), definition)); + var model = com.google.gson.JsonParser.parseString(new String( + assets.get(ResourceLocation.parse("test:models/block/crusher.json")), + java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("mmdlib:conditional_machine", model.get("loader").getAsString()); + assertEquals("test:block/crusher_base", + model.getAsJsonObject("base").get("parent").getAsString()); + assertEquals("test:block/light", model.getAsJsonArray("parts").get(0).getAsJsonObject() + .getAsJsonObject("model").get("parent").getAsString()); + assertTrue( + assets.containsKey(ResourceLocation.parse("test:models/block/crusher_base.json"))); + for (String invalid : List.of( + xml.replace(".running", ".typo"), + xml.replace(".running", ".progress"), + xml.replace("id=\"indicator\"", "id=\"base\""))) { + write(invalid); + var error = assertThrows(IllegalArgumentException.class, + () -> MachineDefinitions.load(sources())); + assertTrue(error.getMessage().contains("crusher.xml")); + } + write(original); + var ordinary = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + var unchanged = zone.moddev.mc.lib.machine.neoforge.MachineResourcePack.build( + net.minecraft.server.packs.PackType.CLIENT_RESOURCES, Map.of(ordinary.id(), ordinary)); + assertFalse( + unchanged.containsKey(ResourceLocation.parse("test:models/block/crusher_base.json"))); + } + @Test void lavaFurnaceInheritsGenericBaseAndExpandsTanksWithoutPower() throws Exception { var xml = Files @@ -71,6 +353,40 @@ void lavaFurnaceInheritsGenericBaseAndExpandsTanksWithoutPower() throws Exceptio } } + @Test + void manualTanksCanRemainRecipeGroupsWithoutAutomation() throws Exception { + var xml = crusher().replace("filter-inputs=\"input\"", "filter-inputs=\"input reagent\"") + .replace("", + """ + + + + + + """); + write(xml); + var machine = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + assertEquals(3, machine.fluids().tanks().size()); + assertFalse(machine.fluids().groups().getFirst().automation()); + assertEquals(zone.moddev.mc.lib.api.capabilities.IoMode.DISABLED, + machine.fluids().groups().getFirst().mode()); + assertTrue(machine.fluids().groups().getLast().automation()); + write(xml.replace("
MachineDefinitions.load(sources())) + .getMessage().contains("role=manual")); + write(xml.replace("automation=\"false\"", "automation=\"invalid\"")); + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())); + } + @Test void inheritsPowerAndExpandsStableGroupsAndCustomProperties() throws Exception { write( diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineInlineColorsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineInlineColorsTest.java new file mode 100644 index 00000000..56c6474d --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineInlineColorsTest.java @@ -0,0 +1,182 @@ +package zone.moddev.mc.lib.machine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import java.util.List; +import java.util.Map; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zone.moddev.mc.lib.common.loading.DefinitionLoader.SourceRoot; +import zone.moddev.mc.lib.gui.load.ScreenException; +import zone.moddev.mc.lib.gui.load.ScreenLoader; +import zone.moddev.mc.lib.gui.model.containers.ColouredGroupElement; +import zone.moddev.mc.lib.machine.definition.MachineDefinition; +import zone.moddev.mc.lib.machine.definition.MachineDefinitions; +import zone.moddev.mc.lib.machine.definition.MachineScreenGenerators; + +class MachineInlineColorsTest { + @TempDir + Path directory; + + private List sources() { + return List.of( + new SourceRoot("mmdlib", Path.of("src/main/resources/mmdlib"), + getClass().getClassLoader()), + new SourceRoot("test", directory, getClass().getClassLoader())); + } + + private String factory() { + return """ + + + + + + + + + + + + + + + + + """; + } + + private MachineDefinition load(String xml) throws Exception { + Files.createDirectories(directory.resolve("machine")); + Files.writeString(directory.resolve("machine/factory.xml"), xml); + return MachineDefinitions.load(sources()).get(ResourceLocation.parse("test:factory")); + } + + @Test + void inlineNamesAndHexReachGroupsAndGeneratedOutlines() throws Exception { + var machine = load(factory()); + assertEquals(0x123abc, machine.power().color()); + assertEquals(128, machine.power().input()); + assertEquals(0, machine.power().output()); + assertEquals(List.of(0xa184ce, 0x4dc6d8), + machine.groups().stream().map(MachineDefinition.Group::color).toList()); + assertEquals(List.of(0x508ee0, 0xee963c), + machine.fluids().groups().stream().map(MachineDefinition.Group::color).toList()); + assertEquals(List.of(1, 2), machine.fluids().groups().get(1).members()); + var screen = ScreenLoader.load(sources(), Map.of(machine.id(), machine.screen()), + (id, node) -> MachineScreenGenerators.generate(machine, node)).get(machine.id()) + .screen(); + assertEquals(0xff4dc6d8, + ((ColouredGroupElement) screen.find("inventory_output").orElseThrow()).colour()); + assertEquals(List.of("inventory_output"), + MachineScreenGenerators.compileHighlights(machine, screen).get("output")); + } + + @Test + void explicitGroupsOverrideInlineColorsWithoutChangingMembers() throws Exception { + var inline = load(factory()); + var explicit = load(factory().replace("
", """ + + + + + +
+ """)); + assertEquals(0xd85b5b, explicit.power().color()); + assertEquals(0xabcdef, explicit.groups().get(1).color()); + assertEquals(0x9c83cf, explicit.fluids().groups().getFirst().color()); + assertEquals(inline.slots(), explicit.slots()); + assertEquals(inline.fluids().tanks(), explicit.fluids().tanks()); + assertEquals(inline.groups().get(1).members(), explicit.groups().get(1).members()); + } + + @Test + void omittedGroupColorsPreserveInlineChoices() throws Exception { + var inline = load(factory()); + var explicit = load(factory().replace("
", """ + + + + +
+ """)); + assertEquals(inline.groups(), explicit.groups()); + assertEquals(inline.fluids(), explicit.fluids()); + } + + @Test + void defaultsAdvancePerGroupNotPerSlotAndKeepExplicitGroupsInOrder() throws Exception { + var xml = factory().replaceAll(" color=\"[^\"]*\"", "") + .replace("", """ + + + + + + + + """); + var plan = load(xml); + assertEquals( + List.of(0x568bd8, 0x4dc6d8, 0x010203, 0xcf84aa, 0x568bd8, 0x83b967, 0x8c9eac, 0xaaa0b7), + plan.groups().stream().map(MachineDefinition.Group::color).toList()); + assertEquals(List.of(0x508ee0, 0xee963c), + plan.fluids().groups().stream().map(MachineDefinition.Group::color).toList()); + assertEquals(0xd85b5b, plan.power().color()); + } + + @Test + void inheritancePreservesAndOverridesInlineColorsAndFingerprints() throws Exception { + load(factory()); + Files.writeString(directory.resolve("machine/base.xml"), + factory().replace("version=\"1\"", "version=\"1\" abstract=\"true\"")); + var child = """ + + + + """; + var plan = load(child); + assertEquals(0x123abc, plan.power().color()); + assertEquals(0xd9bb62, plan.groups().get(1).color()); + assertEquals(0xa184ce, plan.groups().getFirst().color()); + assertEquals(4, plan.groups().get(1).members().size()); + assertNotEquals(plan.checksum(), + load(child.replace("output.tertiary", "output.primary")).checksum()); + } + + @Test + void invalidColorsFailAtTheirOriginalSourceEvenWhenOverridden() throws Exception { + for (var value : List.of("input_99", "#12345", "#gg0000", "#12345678", "")) { + var error = assertThrows(ScreenException.class, + () -> load(factory().replace("input.secondary", value))); + assertTrue(error.getMessage().contains("factory.xml:"), error.getMessage()); + assertTrue(error.getMessage().contains("color='" + value + "'"), error.getMessage()); + assertTrue(error.getMessage().contains("input.primary"), error.getMessage()); + } + load(factory()); + Files.writeString(directory.resolve("machine/base.xml"), factory() + .replace("version=\"1\"", "version=\"1\" abstract=\"true\"") + .replace("input.secondary", "typo")); + var error = assertThrows(ScreenException.class, () -> load( + """ + + + + """)); + assertTrue(error.getMessage().contains("base.xml:"), error.getMessage()); + assertTrue(error.getMessage().contains("color='typo'"), error.getMessage()); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/multiblock/StructureTransformTest.java b/src/test/java/zone/moddev/mc/lib/machine/multiblock/StructureTransformTest.java new file mode 100644 index 00000000..83128a85 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/multiblock/StructureTransformTest.java @@ -0,0 +1,40 @@ +package zone.moddev.mc.lib.machine.multiblock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.UUID; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +class StructureTransformTest { + @Test + void rotatesPositionsAndFacesTogether() { + var origin = new BlockPos(-17, 64, 31); + for (var facing : Direction.Plane.HORIZONTAL) { + var transform = new StructureTransform(origin, facing); + assertEquals(origin.relative(facing, 3).above(2), + transform.position(new BlockPos(0, 2, -3))); + for (var local : Direction.values()) + assertEquals(local, transform.local(transform.direction(local))); + } + assertThrows(IllegalArgumentException.class, + () -> new StructureTransform(origin, Direction.UP)); + } + + @Test + void linksCopyMutableCoordinatesAndRoundTripIdentity() { + var mutable = new BlockPos.MutableBlockPos(1, 2, 3); + var link = new MemberLink(ResourceLocation.parse("minecraft:overworld"), mutable, + UUID.randomUUID(), "belt/0"); + mutable.set(9, 9, 9); + assertEquals(new BlockPos(1, 2, 3), link.controller()); + assertEquals(link, MemberLink.load(link.save())); + assertThrows(IllegalArgumentException.class, + () -> new MemberLink(link.dimension(), BlockPos.ZERO, link.formation(), "")); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/presentation/EmissiveMaskTest.java b/src/test/java/zone/moddev/mc/lib/machine/presentation/EmissiveMaskTest.java new file mode 100644 index 00000000..a00a59e7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/presentation/EmissiveMaskTest.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.lib.machine.presentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class EmissiveMaskTest { + @Test + void partitionsFrameAndDotsWithoutLightingTheCenterOrOverlappingRegions() { + int width = 32, height = 32; + var mask = EmissiveMask.read(width, height, (x, + y) -> (x >= 6 && x < 26 && y >= 6 && y < 26 && !(x >= 8 && x < 24 && y >= 8 && y < 24)) + || (x == 2 && y == 28) ? 1 : 0); + int[][] visits = new int[height][width]; + boolean[][] lit = new boolean[height][width]; + for (var region : mask.regions()) + for (int y = region.y0(); y < region.y1(); y++) + for (int x = region.x0(); x < region.x1(); x++) { + visits[y][x]++; + lit[y][x] = region.emissive(); + } + int glowingPixels = 0; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) { + assertEquals(1, visits[y][x], "Each mask pixel belongs to exactly one region"); + if (lit[y][x]) + glowingPixels++; + } + assertEquals(145, glowingPixels); + assertEquals(false, lit[16][16]); + assertEquals(true, lit[6][6]); + assertEquals(true, lit[28][2]); + } + + @Test + void uniformMasksMergeToOneRegionAndExcessiveMasksFailEarly() { + assertEquals(1, EmissiveMask.read(32, 32, (x, y) -> 1).regions().size()); + assertEquals(1, EmissiveMask.read(32, 32, (x, y) -> 0).regions().size()); + assertThrows(IllegalArgumentException.class, () -> EmissiveMask.read(257, 32, (x, y) -> 0)); + assertThrows(IllegalArgumentException.class, + () -> EmissiveMask.read(128, 128, (x, y) -> (x + y) % 2)); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/presentation/MachineVisualStateTest.java b/src/test/java/zone/moddev/mc/lib/machine/presentation/MachineVisualStateTest.java new file mode 100644 index 00000000..6d4c3d17 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/presentation/MachineVisualStateTest.java @@ -0,0 +1,131 @@ +package zone.moddev.mc.lib.machine.presentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import net.minecraft.nbt.CompoundTag; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.machine.neoforge.MachineVisualState; + +class MachineVisualStateTest { + @Test + void trackingSnapshotsOnlyIncludeReferencedValuesAndRebuildOnConditionTransitions() { + var fill = new AtomicInteger(); + var variables = new VisualVariables.Builder() + .add("tank.fill", 0.0, () -> fill.get() / 100.0) + .add("unreferenced", "private", () -> "contents") + .build(); + var server = new MachineVisualState(variables, List.of("$tank.fill >= 0.5")); + var client = new MachineVisualState(variables, List.of("$tank.fill >= 0.5")); + var initial = server.write(); + assertEquals(Set.of("tank.fill"), initial.getAllKeys()); + assertFalse(client.apply(initial)); + fill.set(60); + variables.changed("tank.fill"); + assertTrue(client.apply(server.write())); + var prior = client.modelData().get(MachineVisualState.VARIABLES); + fill.set(80); + variables.changed("tank.fill"); + assertFalse(client.apply(server.write())); + assertEquals(0.6, prior.get("tank.fill")); + assertEquals(0.8, client.modelData().get(MachineVisualState.VARIABLES).get("tank.fill")); + // A newly tracking client receives current state without needing an open menu. + var joining = new MachineVisualState(variables, List.of("$tank.fill >= 0.5")); + assertTrue(joining.apply(server.write())); + fill.set(0); + variables.changed("tank.fill"); + assertTrue(client.apply(server.write())); + } + + @Test + void notificationsScheduleOneUpdateAndCleanSnapshotsNeverPollGetters() { + var changed = new zone.moddev.mc.lib.api.util.ChangeSignal(); + var unrelated = new zone.moddev.mc.lib.api.util.ChangeSignal(); + var value = new AtomicInteger(); + var reads = new AtomicInteger(); + var schedules = new AtomicInteger(); + var broadcasts = new AtomicInteger(); + var variables = new VisualVariables.Builder().observedBy(changed) + .add("value", 0.0, () -> { + reads.incrementAndGet(); + return value.get(); + }) + .observedBy(unrelated) + .add("unused", false, () -> { + throw new AssertionError("Unreferenced getter"); + }) + .build(); + var state = new MachineVisualState(variables, List.of("$value > 0")); + state.start(schedules::incrementAndGet); + state.flush(broadcasts::incrementAndGet); + assertEquals(1, reads.get()); + assertEquals(1, schedules.get()); + for (int i = 0; i < 100; i++) + state.write(); + unrelated.changed(); + assertEquals(1, reads.get(), "Initial snapshots and unrelated changes use cached values"); + assertEquals(1, schedules.get()); + value.set(1); + changed.changed(); + changed.changed(); + assertEquals(2, schedules.get(), "Mutations coalesce into one pending update"); + state.write(); // A newly tracking player must not consume the pending broadcast. + state.flush(broadcasts::incrementAndGet); + assertEquals(2, broadcasts.get()); + assertEquals(2, reads.get()); + changed.changed(); + state.flush(broadcasts::incrementAndGet); + assertEquals(2, broadcasts.get(), "A notification without a value change sends nothing"); + state.stop(); + value.set(0); + changed.changed(); + assertEquals(3, schedules.get(), "Unloaded owners have no listeners"); + state.start(schedules::incrementAndGet); + state.flush(broadcasts::incrementAndGet); + assertEquals(3, broadcasts.get(), "Reload samples current values and reconnects listeners"); + } + + @Test + void energyCapacityAndRestorationInvalidateSnapshotsWithoutReplacingPersistenceCallback() { + var persisted = new AtomicInteger(); + var energy = new zone.moddev.mc.lib.api.capabilities.EnergyBuffer(100, 100) + .onChange(persisted::incrementAndGet); + var variables = new VisualVariables.Builder() + .component("energy", BuiltinVisualVariables.energy(energy)).build(); + var state = new MachineVisualState(variables, List.of("$energy.fill >= 0.5")); + assertEquals(0.0, state.write().getDouble("energy.fill")); + energy.onChange(() -> { + persisted.incrementAndGet(); + assertEquals((double) energy.stored() / energy.capacity(), + state.write().getDouble("energy.fill"), + "Persistence callbacks can send a current snapshot immediately"); + }); + energy.setStored(50); + assertEquals(0.5, state.write().getDouble("energy.fill")); + assertEquals(1, persisted.get()); + energy.setCapacity(200); + assertEquals(0.25, state.write().getDouble("energy.fill")); + energy.deserializeNBT(null, net.minecraft.nbt.LongTag.valueOf(100)); + assertEquals(0.5, state.write().getDouble("energy.fill")); + } + + @Test + void malformedAndMissingWireValuesResetToDefaultsWithoutRetainingOldState() { + var variables = new VisualVariables.Builder().add("running", false, () -> true).build(); + var server = new MachineVisualState(variables, List.of("$running")); + var client = new MachineVisualState(variables, List.of("$running")); + assertTrue(client.apply(server.write())); + var malformed = new CompoundTag(); + malformed.putString("running", "true"); + assertTrue(client.apply(malformed)); + assertEquals(false, client.modelData().get(MachineVisualState.VARIABLES).get("running")); + assertFalse(client.apply(new CompoundTag())); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualConditionTest.java b/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualConditionTest.java new file mode 100644 index 00000000..4299b5a0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualConditionTest.java @@ -0,0 +1,53 @@ +package zone.moddev.mc.lib.machine.presentation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class VisualConditionTest { + private static final Map DEFAULTS = Map.of( + "processors.mixer.running", false, + "processors.mixer.status", "idle", + "tanks.water.fill", 0.0, + "inventories.output.count", 0.0, + "tanks.input/0.fill", 0.0); + + @Test + void combinesIndependentComponentsWithTypedComparisonsAndArithmetic() { + var condition = VisualCondition.compile( + "($processors.mixer.running && $tanks.water.fill >= 0.5) || " + + "($processors.mixer.status == 'blocked' && $inventories.output.count / 2 > 3)", + DEFAULTS); + assertFalse(condition.test(DEFAULTS)); + assertTrue( + condition.test(Map.of("processors.mixer.running", true, "tanks.water.fill", 0.75))); + assertFalse( + condition.test(Map.of("processors.mixer.running", true, "tanks.water.fill", 0.25))); + assertTrue(condition + .test(Map.of("processors.mixer.status", "blocked", "inventories.output.count", 8.0))); + assertFalse(condition + .test(Map.of("processors.mixer.status", "blocked", "inventories.output.count", 6.0))); + } + + @Test + void supportsDefaultsNegationPrecedenceAndEscapedComponentIds() { + assertTrue(VisualCondition.compile("!$processors.mixer.running", DEFAULTS).test(Map.of())); + assertTrue(VisualCondition.compile("true || false && false", DEFAULTS).test(Map.of())); + assertTrue(VisualCondition.compile("${tanks.input/0.fill} == 0", DEFAULTS).test(Map.of())); + assertTrue(VisualCondition.compile("-2 + 3 * 4 == 10", DEFAULTS).test(Map.of())); + } + + @Test + void rejectsUnknownVariablesInvalidTypesAndMalformedConditionsAtLoadTime() { + for (var source : new String[]{"$processors.typo.running", "$tanks.water.fill", + "$tanks.water.fill && true", "$processors.mixer.running > 0", + "$processors.mixer.status == 1", "true junk", "true ||", "(true", "'unterminated", + "(".repeat(70) + "true" + ")".repeat(70)}) + assertThrows(IllegalArgumentException.class, + () -> VisualCondition.compile(source, DEFAULTS), source); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualVariablesTest.java b/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualVariablesTest.java new file mode 100644 index 00000000..b1608693 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/presentation/VisualVariablesTest.java @@ -0,0 +1,47 @@ +package zone.moddev.mc.lib.machine.presentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +class VisualVariablesTest { + @Test + void customComponentsComposeWithoutRendererKnowledgeAndSnapshotsAreDetached() { + var temperature = new AtomicInteger(12); + VisualVariableProvider kiln = vars -> vars.add("temperature", 0, temperature::get); + var variables = new VisualVariables.Builder() + .component("custom.kiln", kiln) + .add("unused", false, () -> { + throw new AssertionError("Unreferenced getter sampled"); + }) + .build(); + var first = variables.snapshot(Set.of("custom.kiln.temperature")); + temperature.set(80); + assertEquals(Map.of("custom.kiln.temperature", 12.0), first); + assertEquals(Map.of("custom.kiln.temperature", 80.0), variables.snapshot(first.keySet())); + assertThrows(UnsupportedOperationException.class, + () -> first.put("custom.kiln.temperature", 2.0)); + assertEquals(0.0, variables.defaults().get("custom.kiln.temperature")); + } + + @Test + void rejectsCollisionsTypeChangesAndNonFiniteValues() { + var builder = new VisualVariables.Builder().component("a", + vars -> vars.add("running", false, () -> true)); + assertThrows(IllegalArgumentException.class, + () -> builder.add("a.running", false, () -> false)); + var invalid = new VisualVariables.Builder().add("value", 0.0, () -> "wrong").build(); + assertThrows(IllegalArgumentException.class, () -> invalid.snapshot(Set.of("value"))); + assertThrows(IllegalArgumentException.class, () -> invalid.snapshot(Set.of("missing"))); + assertThrows(IllegalArgumentException.class, + () -> new VisualVariables.Builder().add("value", Double.NaN, () -> 0)); + var infinity = new VisualVariables.Builder() + .add("value", 0.0, () -> Double.POSITIVE_INFINITY).build(); + assertThrows(IllegalArgumentException.class, () -> infinity.snapshot(Set.of("value"))); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java b/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java index 2d97e14e..71f4ea35 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/recipe/InputClaimsTest.java @@ -5,80 +5,84 @@ import org.junit.jupiter.api.Test; class InputClaimsTest { - @Test - void broadClaimMovesOffTheOnlyEntryALaterClaimAccepts() { - var claims = new InputClaims(new long[] {1, 1}); - assertEquals(0, claims.claim(1, any -> true)); - assertEquals(0, claims.claim(1, entry -> entry == 0)); - assertEquals(1, claims.claimed(0)); - assertEquals(1, claims.claimed(1)); - } + @Test + void broadClaimMovesOffTheOnlyEntryALaterClaimAccepts() { + var claims = new InputClaims(new long[]{1, 1}); + assertEquals(0, claims.claim(1, any -> true)); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + assertEquals(0, claims.claimed(0, 0)); + assertEquals(1, claims.claimed(0, 1)); + assertEquals(1, claims.claimed(1, 0)); + assertEquals(0, claims.claimed(1, 1)); + } - @Test - void orderOfTheClaimsDoesntMatter() { - var claims = new InputClaims(new long[] {1, 1}); - assertEquals(0, claims.claim(1, entry -> entry == 0)); - assertEquals(0, claims.claim(1, any -> true)); - assertEquals(1, claims.claimed(0)); - assertEquals(1, claims.claimed(1)); - } + @Test + void orderOfTheClaimsDoesntMatter() { + var claims = new InputClaims(new long[]{1, 1}); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(0, claims.claim(1, any -> true)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + } - @Test - void movesFollowAChain() { - var claims = new InputClaims(new long[] {1, 1, 1}); - assertEquals(0, claims.claim(1, entry -> entry != 1)); - assertEquals(0, claims.claim(1, entry -> entry != 2)); - assertEquals(0, claims.claim(1, entry -> entry == 0)); - assertEquals(1, claims.claimed(0)); - assertEquals(1, claims.claimed(1)); - assertEquals(1, claims.claimed(2)); - } + @Test + void movesFollowAChain() { + var claims = new InputClaims(new long[]{1, 1, 1}); + assertEquals(0, claims.claim(1, entry -> entry != 1)); + assertEquals(0, claims.claim(1, entry -> entry != 2)); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(1, claims.claimed(1)); + assertEquals(1, claims.claimed(2)); + } - @Test - void partOfAStackMovesAndTheRestStays() { - var claims = new InputClaims(new long[] {3, 3}); - assertEquals(0, claims.claim(4, any -> true)); - assertEquals(0, claims.claim(2, entry -> entry == 0)); - assertEquals(3, claims.claimed(0)); - assertEquals(3, claims.claimed(1)); - } + @Test + void partOfAStackMovesAndTheRestStays() { + var claims = new InputClaims(new long[]{3, 3}); + assertEquals(0, claims.claim(4, any -> true)); + assertEquals(0, claims.claim(2, entry -> entry == 0)); + assertEquals(3, claims.claimed(0)); + assertEquals(3, claims.claimed(1)); + } - @Test - void nothingCountsTwiceAndAShortClaimReportsWhatsMissing() { - var claims = new InputClaims(new long[] {3}); - assertEquals(0, claims.claim(2, any -> true)); - assertEquals(1, claims.claim(2, any -> true)); - assertEquals(3, claims.claimed(0)); - } + @Test + void nothingCountsTwiceAndAShortClaimReportsWhatsMissing() { + var claims = new InputClaims(new long[]{3}); + assertEquals(0, claims.claim(2, any -> true)); + assertEquals(1, claims.claim(2, any -> true)); + assertEquals(3, claims.claimed(0)); + } - @Test - void claimsOnTheSameSingleEntryCantBothBeMet() { - var claims = new InputClaims(new long[] {1, 1}); - assertEquals(0, claims.claim(1, entry -> entry == 0)); - assertEquals(1, claims.claim(1, entry -> entry == 0)); - assertEquals(1, claims.claimed(0)); - assertEquals(0, claims.claimed(1)); - } + @Test + void claimsOnTheSameSingleEntryCantBothBeMet() { + var claims = new InputClaims(new long[]{1, 1}); + assertEquals(0, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claim(1, entry -> entry == 0)); + assertEquals(1, claims.claimed(0)); + assertEquals(0, claims.claimed(1)); + } - // So an ingredient never tests an empty stack. - @Test - void emptyEntriesAreNotAskedAbout() { - var claims = new InputClaims(new long[] {0, 2}); - assertEquals( - 0, - claims.claim( - 1, - entry -> { - assertEquals(1, entry); - return true; - })); - } + // So an ingredient never tests an empty stack. + @Test + void emptyEntriesAreNotAskedAbout() { + var claims = new InputClaims(new long[]{0, 2}); + assertEquals( + 0, + claims.claim( + 1, + entry -> { + assertEquals(1, entry); + return true; + })); + } - // Energy amounts don't fit an int. - @Test - void amountsLargerThanAnInt() { - var claims = new InputClaims(new long[] {3_000_000_000L}); - assertEquals(0, claims.claim(2_999_999_999L, any -> true)); - assertEquals(1, claims.claim(2, any -> true)); - } + // Energy amounts don't fit an int. + @Test + void amountsLargerThanAnInt() { + var claims = new InputClaims(new long[]{3_000_000_000L}); + assertEquals(0, claims.claim(2_999_999_999L, any -> true)); + assertEquals(1, claims.claim(2, any -> true)); + } } From a127d7f6641ffaf93cd05061de365fcd388e1c06 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Thu, 12 Nov 2026 18:31:53 +0200 Subject: [PATCH 2/4] Fix recipe selector test fixtures and registry-dependent coverage --- .../test/MachineRecipeLoadingTests.java | 39 +++++++++++++++++++ .../moddev/mc/lib/gui/RecipeSelectorTest.java | 34 +--------------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java index 47ecdf0f..2d36d968 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java @@ -24,6 +24,7 @@ import zone.moddev.mc.lib.gui.Screens; import zone.moddev.mc.lib.gui.runtime.MachineMenu; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; import zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity; import zone.moddev.mc.lib.machine.neoforge.GeneratedMachineRecipes; import zone.moddev.mc.lib.machine.neoforge.MachineRecipeTypes; @@ -64,6 +65,44 @@ private static void require(boolean condition, String message) { throw new AssertionError(message); } + @GameTest(template = "empty") + public static void oldSaveAndExplicitlyClearedSelectionHaveDifferentPersistentStates( + GameTestHelper h) { + var ops = h.getLevel().registryAccess() + .createSerializationContext(JsonOps.INSTANCE); + var legacy = RecipeProcessor.State.CODEC + .parse(ops, JsonParser.parseString("{}")).getOrThrow(); + require(legacy.selection().isEmpty(), + "Legacy saves have no selection setting"); + var cleared = RecipeProcessor.State.CODEC + .parse(ops, JsonParser.parseString("{\"selection\":{}}")) + .getOrThrow(); + require(cleared.selection().isPresent(), + "Explicit clearing retains a selection setting"); + require(cleared.selection().orElseThrow().recipe().isEmpty(), + "Explicit clearing retains no recipe"); + var selected = RecipeProcessor.State.CODEC + .parse(ops, + JsonParser + .parseString("{\"selection\":{\"recipe\":\"test:stone\"}}")) + .getOrThrow(); + require( + selected.selection().orElseThrow().recipe().orElseThrow() + .equals(ResourceLocation.parse("test:stone")), + "Selected recipe ID is preserved"); + require(!selected.equals(cleared), + "Selected and cleared states remain distinct"); + for (var state : List.of(legacy, cleared, selected)) { + var encoded = RecipeProcessor.State.CODEC.encodeStart(ops, state) + .getOrThrow(); + require( + state.equals(RecipeProcessor.State.CODEC.parse(ops, encoded) + .getOrThrow()), + "Selection state survives codec round trips"); + } + h.succeed(); + } + @GameTest(template = "empty") public static void authoredRecipesValidateGroupsAndResourceKinds( GameTestHelper h) { diff --git a/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java b/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java index 33446287..8016f91e 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/RecipeSelectorTest.java @@ -1,9 +1,7 @@ package zone.moddev.mc.lib.gui; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; @@ -12,21 +10,17 @@ import java.util.Map; import java.util.Set; -import com.google.gson.JsonParser; - -import com.mojang.serialization.JsonOps; - import net.minecraft.resources.ResourceLocation; import org.junit.jupiter.api.Test; import zone.moddev.mc.lib.gui.load.ScreenReader; import zone.moddev.mc.lib.gui.model.elements.RecipeSelectorElement; -import zone.moddev.mc.lib.machine.feature.RecipeProcessor; class RecipeSelectorTest { private RecipeSelectorElement read(String attributes) throws Exception { - String xml = "" + String xml = "" + ""; return (RecipeSelectorElement) ScreenReader .read(ResourceLocation.parse("test:selector"), "selector.xml", @@ -54,28 +48,4 @@ void selectorOwnsStateAndActionsAndValidatesItsProcessorBinding() () -> read(attributes)); } - @Test - void oldSaveAndExplicitlyClearedSelectionHaveDifferentPersistentStates() { - var legacy = RecipeProcessor.State.CODEC - .parse(JsonOps.INSTANCE, JsonParser.parseString("{}")).getOrThrow(); - assertTrue(legacy.selection().isEmpty()); - var cleared = RecipeProcessor.State.CODEC.parse(JsonOps.INSTANCE, - JsonParser.parseString("{\"selection\":{}}")).getOrThrow(); - assertTrue(cleared.selection().isPresent()); - assertTrue(cleared.selection().orElseThrow().recipe().isEmpty()); - var selected = RecipeProcessor.State.CODEC - .parse(JsonOps.INSTANCE, - JsonParser - .parseString("{\"selection\":{\"recipe\":\"test:stone\"}}")) - .getOrThrow(); - assertEquals(ResourceLocation.parse("test:stone"), - selected.selection().orElseThrow().recipe().orElseThrow()); - assertFalse(selected.equals(cleared)); - for (var state : java.util.List.of(legacy, cleared, selected)) { - var encoded = RecipeProcessor.State.CODEC - .encodeStart(JsonOps.INSTANCE, state).getOrThrow(); - assertEquals(state, RecipeProcessor.State.CODEC - .parse(JsonOps.INSTANCE, encoded).getOrThrow()); - } - } } From 89bcd3dd77ee34c86a0a5a14c8267401f392380d Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Thu, 12 Nov 2026 18:50:19 +0200 Subject: [PATCH 3/4] Address multiblock lifecycle, recipe and compatibility review findings --- docs/multiblocks.md | 8 +- docs/xml-machines.md | 6 +- .../mc/companion/MultiblockDemoEntity.java | 11 +- .../MachineDefinitionValidationTests.java | 173 ++++++++++++++++++ .../mc/companion/test/MultiblockTests.java | 113 +++++++++++- .../model/elements/RecipeSelectorElement.java | 31 +++- .../machine/definition/MachineDefinition.java | 18 +- .../machine/inventory/MachineStorageKeys.java | 3 +- .../multiblock/MultiblockInstance.java | 7 +- .../mc/lib/machine/neoforge/BaseMachine.java | 21 ++- .../neoforge/DefinedMachineEntity.java | 24 ++- .../neoforge/GeneratedMachineRecipes.java | 95 +++++----- .../lib/machine/neoforge/MachineContent.java | 30 ++- .../lib/machine/neoforge/MultiblockBlock.java | 17 +- .../lib/machine/neoforge/StorageGroups.java | 13 +- .../elements/RecipeSelectorCostTests.java | 57 ++++++ .../moddev/mc/lib/smoke/ProcessorSmoke.java | 14 +- .../lib/machine/MachineDefinitionsTest.java | 32 ++++ 18 files changed, 569 insertions(+), 104 deletions(-) create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/MachineDefinitionValidationTests.java create mode 100644 src/runtimeTest/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorCostTests.java diff --git a/docs/multiblocks.md b/docs/multiblocks.md index c2e65e86..1dae52e9 100644 --- a/docs/multiblocks.md +++ b/docs/multiblocks.md @@ -70,9 +70,9 @@ structure = new MultiblockInstance(this, MyMachine.definition(), `BaseMachine` maintains the instance before processing, persists its manifest and synchronizes presentation state. While unavailable it suspends features, skips custom before/after processing hooks and blocks lifecycle automation transfers. Refund settlement still runs because already-owed resources must be returned. Other code that pushes resources outside these hooks must check `structureReady()`. -The statuses are `UNFORMED`, `READY`, `SUSPENDED` and `DISASSEMBLING`. Ready means structurally available, not powered or actively processing. An unavailable member chunk suspends the instance without loading that chunk or deleting members. A known missing/wrong member dismantles according to policy. Reloaded manifests are suspended until verified; incompatible manifests remain inactive and are retained alongside storage. +The statuses are `UNFORMED`, `READY`, `SUSPENDED` and `DISASSEMBLING`. Ready means structurally available, not powered or actively processing. An unavailable member chunk suspends the instance without loading that chunk or deleting members. A known missing/wrong member dismantles according to policy. Manifests are saved under `mmdlib_machine.multiblock`; earlier flat manifests still load. Reloaded manifests are suspended until verified; incompatible manifests remain inactive and are retained alongside storage. -The current fixed-layout runtime validates its bounded membership on readiness checks, including retained port access. It does not rediscover structure shape. Large variable structures will need indexed invalidation/bounded validation scheduling when their resolver support is added. +The current fixed-layout runtime validates its bounded membership once per controller tick. `ready()` only reads the last lifecycle result; capability access never scans or dismantles the structure. Retained ports still verify their controller and accessed member identities on every call. World removals invalidate through block hooks; obstruction and chunk availability are refreshed on the next controller tick. It does not rediscover structure shape. Large variable structures will need indexed invalidation/bounded validation scheduling when their resolver support is added. Other block entities can implement `MultiblockController` and compose the instance directly. They must call its tick/save/load/client-state hooks and apply processing/transfer gates themselves. Only `BaseMachine` supplies that wiring automatically. @@ -80,9 +80,9 @@ Other block entities can implement `MultiblockController` and compose the instan The library revokes processing and port access before invoking `dismantling`. The consumer owns feature cancellation, resource recovery and machine loot. Give physical members no independent machine-item loot; otherwise ordinary block destruction could duplicate the callback's item drop. -The companion drops stored items once and awards one machine item only for an ordinary survival break. Creative destruction awards no machine item. Generic replacement/explosion/removal paths use recovery-only cleanup; a custom block can supply a more specific policy. If the original break context is unavailable after chunk loading, recovery does not invent survival loot. +The companion drops stored items once and awards one machine item for a survival break or explosion (`SURVIVAL`/`EXPLOSION`). Creative destruction awards no machine item. Generic replacements and detected broken membership use `INVALIDATED`; interrupted placement uses `RECOVERY`. Neither awards a machine item. If the original break context is unavailable after chunk loading, recovery does not invent survival loot. -`REMOVE_OWNED` removes only matching members with reciprocal ownership, with the controller last. Reentrant removal cannot repeat the dismantling callback. `LEAVE_BLOCKS` clears links and leaves blocks intact; its callback should retain storage and settle reservations without dropping the machine. Storage reduction during a future reformation must use recovery rather than truncation. +`REMOVE_OWNED` removes only matching members with reciprocal ownership, with the controller last. Reentrant removal cannot repeat the dismantling callback. `LEAVE_BLOCKS` clears links and leaves blocks intact; its callback should retain storage and settle reservations without dropping the machine. If the controller itself is removed, `MultiblockBlock` invokes its `DropsContents` hook before removing the block entity, including unformed and `LEAVE_BLOCKS` controllers. Implement that hook for custom storage; it must drain what it drops so repeated removal callbacks cannot duplicate contents. Storage reduction during a future reformation must use recovery rather than truncation. Handle world interactions such as animal restraint on controller/chunk unload as well as lifecycle transitions. An unloaded controller cannot keep running a cleanup tick. Store persistent animal fitness on the animal, not in a part block. diff --git a/docs/xml-machines.md b/docs/xml-machines.md index 5724beef..6950d9ff 100644 --- a/docs/xml-machines.md +++ b/docs/xml-machines.md @@ -76,7 +76,7 @@ Declare a sample-only inventory alongside ordinary item storage: `` also works; `count` is optional on the singular form. Ghost inventories require `role="input"`, have one-item samples rather than stack limits, and are included in the 54-slot limit. -`default-item` is optional; without it the initial sample is empty. Ordinary slots +`default-item` is optional; without it the initial sample is empty. Unknown items and `minecraft:air` are rejected during startup after item registration, with the machine and ghost group identified. Ordinary slots retain their default `type="item"` behavior. Recipes use normal item ingredients; the storage type supplies the non-consuming @@ -100,12 +100,12 @@ An explicit screen can use ` recoverResources(level, worldPosition, payload)); - if (cause == MultiblockInstance.Cause.SURVIVAL) + if (cause == MultiblockInstance.Cause.SURVIVAL + || cause == MultiblockInstance.Cause.EXPLOSION) net.minecraft.world.Containers.dropItemStack(level, worldPosition.getX() + .5, worldPosition.getY() + .5, @@ -105,6 +106,14 @@ protected void loadMachineData(CompoundTag tag, fluids.deserializeNBT(registries, tag.getCompound("fluids")); operations = tag.getInt("operations"); } + @Override + public void dropContents(Level world, BlockPos pos) { + features.reset(); + items.dropAll(world, pos, + payload -> recoverResources(world, pos, payload)); + super.dropContents(world, pos); + } + @Override protected void recoverResources(Level world, BlockPos pos, CompoundTag payload) { diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MachineDefinitionValidationTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MachineDefinitionValidationTests.java new file mode 100644 index 00000000..1dd771b5 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MachineDefinitionValidationTests.java @@ -0,0 +1,173 @@ +package zone.moddev.mc.companion.test; + +import java.util.List; +import java.util.Map; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; + +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import zone.moddev.mc.lib.machine.definition.MachineDefinition; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity; +import zone.moddev.mc.lib.machine.neoforge.GeneratedMachineRecipes; +import zone.moddev.mc.lib.machine.neoforge.MachineContent; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; + +@GameTestHolder("mmdlib_companion") +@PrefixGameTestTemplate(false) +public final class MachineDefinitionValidationTests { + private static MachineDefinition definition(String storage, int samples, + String item) { + var d = MachineContent.definitions() + .get(ResourceLocation.parse("mmdlib_companion:simple_crusher")); + return new MachineDefinition(d.id(), d.checksum(), d.name(), + d.hardness(), d.mineable(), d.requiresTool(), d.textures(), + d.baseModel(), d.modelParts(), d.faceTextures(), d.power(), + d.fluids(), storage, d.slots(), d.groups(), d.recipeType(), + d.processor(), d.processorName(), d.redstone(), d.screen(), + d.recipeSelection(), List.of(new MachineDefinition.Ghost("pattern", + samples, item == null ? null : ResourceLocation.parse(item)))); + } + + @GameTest(template = "empty") + public static void ghostDefaultsValidateBeforeWorldConstruction( + GameTestHelper h) { + validateResources(definition("items", 1, "minecraft:stone")); + validateResources(definition("items", 1, null)); + for (var item : List.of("mmdlib_companion:typo", "minecraft:air")) { + try { + validateResources(definition("items", 1, item)); + throw new AssertionError( + "Invalid ghost default accepted: " + item); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("simple_crusher") + && expected.getMessage().contains("pattern") + && expected.getMessage().contains(item), + "Startup diagnostic identifies the machine, ghost group and invalid item"); + } + } + h.succeed(); + } + + @GameTest(template = "empty") + public static void ghostRecipesCannotRequireMoreSamplesThanSlots( + GameTestHelper h) { + var one = new MachineIngredient.ItemPart(Ingredient.of(Items.STONE), 1); + var two = new MachineIngredient.ItemPart(Ingredient.of(Items.STONE), 2); + validateGroups(definition("items", 1, null), + new MachineRecipe(Map.of("pattern", List.of(one)), Map.of(), 1)); + // Multiple samples can jointly satisfy a count greater than one. + validateGroups(definition("items", 2, null), + new MachineRecipe(Map.of("pattern", List.of(two)), Map.of(), 1)); + for (List inputs : List.of( + List.of(two), + List.of(one, one))) { + try { + validateGroups(definition("items", 1, null), + new MachineRecipe(Map.of("pattern", inputs), Map.of(), 1)); + throw new AssertionError("Impossible ghost recipe accepted"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("pattern") + && expected.getMessage().contains("samples"), + "Diagnostic identifies the impossible sample requirement"); + } + } + h.succeed(); + } + + private static DefinedMachineEntity entity(MachineDefinition d) { + return new DefinedMachineEntity( + BuiltInRegistries.BLOCK_ENTITY_TYPE.get(d.id()), BlockPos.ZERO, + BuiltInRegistries.BLOCK.get(d.id()).defaultBlockState(), d, + MachineContent.recipeType(d.recipeType().id())); + } + + @GameTest(template = "empty") + public static void oldStorageNamesAndGhostsRoundTripIndependently( + GameTestHelper h) { + for (var key : List.of("multiblock", "multiblock_view", + "mmdlib_ghost_items")) { + var d = definition(key, 1, "minecraft:stone"); + var original = entity(d); + original.items.insertResult(0, new ItemStack(Items.DIAMOND, 3), + false); + original.setScreenGhost(null, "pattern", 0, + new ItemStack(Items.GOLD_INGOT)); + var saved = original + .saveWithFullMetadata(h.getLevel().registryAccess()); + require(saved.contains(key), + "Consumer storage keeps its original save key"); + require( + saved.getCompound("mmdlib_machine") + .contains("mmdlib_ghost_items"), + "Ghost metadata uses the existing reserved namespace"); + var restored = entity(d); + restored.loadWithComponents(saved, h.getLevel().registryAccess()); + require( + restored.items.getStackInSlot(0).getCount() == 3 + && restored.items.getStackInSlot(0).is(Items.DIAMOND), + "Real items survive reload"); + require(restored.screenGhost("pattern", 0).is(Items.GOLD_INGOT), + "Ghost sample survives reload"); + } + var d = definition("items", 1, "minecraft:stone"); + var original = entity(d); + original.setScreenGhost(null, "pattern", 0, + new ItemStack(Items.GOLD_INGOT)); + var saved = original + .saveWithFullMetadata(h.getLevel().registryAccess()); + saved.put("mmdlib_ghost_items", saved.getCompound("mmdlib_machine") + .getCompound("mmdlib_ghost_items")); + saved.remove("mmdlib_machine"); + var restored = entity(d); + restored.loadWithComponents(saved, h.getLevel().registryAccess()); + require(restored.screenGhost("pattern", 0).is(Items.GOLD_INGOT), + "Pre-release flat ghost saves migrate"); + h.succeed(); + } + + private static void validateResources(MachineDefinition definition) { + validate(MachineContent.class, "validateResources", + new Class[]{MachineDefinition.class}, definition); + } + + private static void validateGroups(MachineDefinition definition, + MachineRecipe recipe) { + validate(GeneratedMachineRecipes.class, "validateGroups", + new Class[]{MachineDefinition.class, MachineRecipe.class}, + definition, recipe); + } + + // Keep production validators internal; the companion lives in a separate + // mod/module. + private static void validate(Class owner, String name, Class[] types, + Object... args) { + try { + var method = owner.getDeclaredMethod(name, types); + method.setAccessible(true); + method.invoke(null, args); + } catch (java.lang.reflect.InvocationTargetException failure) { + if (failure.getCause() instanceof RuntimeException cause) + throw cause; + throw new AssertionError(failure.getCause()); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + + private static void require(boolean value, String message) { + if (!value) + throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java index 16426ab3..508f988e 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MultiblockTests.java @@ -39,6 +39,93 @@ @GameTestHolder("mmdlib_companion") @PrefixGameTestTemplate(false) public final class MultiblockTests { + @GameTest(template = "empty") + public static void capabilityQueriesDoNotValidateOrDismantle( + GameTestHelper helper) { + var base = MultiblockDemo.definition(); + int[] validations = {0}; + var definition = new zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition( + base.id(), base.version(), base.resolver(), base.maxMembers(), + base.maxExtent(), base.dismantling(), + Map.of("clearance", (world, position) -> { + validations[0]++; + return world.getBlockState(position).isAir(); + }), base.ports()); + var level = helper.getLevel(); + var pos = origin(helper); + var candidate = ((zone.moddev.mc.lib.machine.multiblock.FixedLayout) base + .resolver()).structure(); + for (var member : candidate.members()) + level.setBlock(pos.offset(member.offset()), member.state(), 3); + var entity = new MultiblockDemoEntity(pos, level.getBlockState(pos), + definition); + level.setBlockEntity(entity); + require(entity.multiblock().form(candidate, Direction.NORTH), + "Fixture forms"); + validations[0] = 0; + entity.serverTick(); + require(validations[0] == 1, "Controller validates once per tick"); + var socket = level.getBlockEntity(socket(entity)); + var items = MultiblockPorts.items(socket, Direction.NORTH); + var fluid = MultiblockPorts.fluids(socket, Direction.NORTH); + var energy = MultiblockPorts.energy(socket, Direction.NORTH); + for (int i = 0; i < 10; i++) { + require(entity.multiblock().ready(), + "Ready snapshot remains readable"); + items.getSlots(); + items.getStackInSlot(0); + items.insertItem(0, new ItemStack(Items.IRON_INGOT), true); + fluid.fill(new FluidStack(Fluids.WATER, 10), + IFluidHandler.FluidAction.SIMULATE); + energy.receiveEnergy(10, true); + } + require(validations[0] == 1, + "Capability reads/transfers never revalidate"); + // A broken reciprocal link is discovered by the lifecycle, not a + // neighboring handler. + ((zone.moddev.mc.lib.machine.multiblock.LinkedMember) level + .getBlockEntity(pos.east())).memberLink(null); + items.getSlots(); + items.getStackInSlot(0); + require(level.getBlockEntity(pos) == entity, + "Capability query cannot dismantle the owner"); + entity.serverTick(); + require(level.getBlockState(pos).isAir(), + "Lifecycle detects invalid membership"); + require(items.getSlots() == 0 && energy.receiveEnergy(10, false) == 0, + "Retained handlers become inert after teardown"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void explosionDropsOnePackagedMachineAndItsContents( + GameTestHelper helper) { + var entity = place(helper, Direction.NORTH); + var level = helper.getLevel(); + entity.items.insertItem(0, new ItemStack(Items.DIAMOND, 3), false); + var pos = socket(entity); + var state = level.getBlockState(pos); + var explosion = new net.minecraft.world.level.Explosion(level, null, + pos.getX(), pos.getY(), pos.getZ(), 1, false, + net.minecraft.world.level.Explosion.BlockInteraction.DESTROY); + state.getBlock().onBlockExploded(state, level, pos, explosion); + var drops = level.getEntitiesOfClass( + net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(origin(helper)).inflate(3)); + require( + drops.stream() + .filter(e -> e.getItem().is(MultiblockDemo.ITEM.get())) + .mapToInt(e -> e.getItem().getCount()).sum() == 1, + "Explosion awards one machine item"); + require( + drops.stream().filter(e -> e.getItem().is(Items.DIAMOND)) + .mapToInt(e -> e.getItem().getCount()).sum() == 3, + "Explosion drops contents once"); + require(level.getBlockState(origin(helper)).isAir(), + "Explosion dismantles the structure"); + helper.succeed(); + } + @GameTest(template = "empty", timeoutTicks = 200) public static void unavailableChunkSuspendsWithoutLoadingAndResumesSameIdentity( GameTestHelper helper) { @@ -140,6 +227,22 @@ public static void existingMembersUseSameLifecycleAndLeaveBlocksPolicy( "Explicit reformation succeeds"); require(!oldIdentity.equals(entity.multiblock().formation()), "Reformation has a new identity"); + var player = helper + .makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL); + var state = level.getBlockState(pos); + state.getBlock().playerWillDestroy(level, pos, state, player); + level.removeBlock(pos, false); + var drops = level.getEntitiesOfClass( + net.minecraft.world.entity.item.ItemEntity.class, + new net.minecraft.world.phys.AABB(pos).inflate(3)); + require( + drops.stream().filter(e -> e.getItem().is(Items.GOLD_INGOT)) + .mapToInt(e -> e.getItem().getCount()).sum() == 4, + "Breaking a leave-blocks controller drops its retained contents exactly once"); + require( + drops.stream() + .noneMatch(e -> e.getItem().is(MultiblockDemo.ITEM.get())), + "A player-built structure does not award a packaged machine"); helper.succeed(); } @@ -373,7 +476,8 @@ public static void manifestReloadAndIncompatibleDefinitionPreserveStorage( restored.multiblock().ready() && formation.equals(restored.multiblock().formation()), "Reload verifies existing identity"); - saved.getCompound("multiblock").putInt("version", 999); + saved.getCompound("mmdlib_machine").getCompound("multiblock") + .putInt("version", 999); var incompatible = (MultiblockDemoEntity) BlockEntity.loadStatic( entity.getBlockPos(), entity.getBlockState(), saved, level.registryAccess()); @@ -383,10 +487,9 @@ public static void manifestReloadAndIncompatibleDefinitionPreserveStorage( "Changed definition is inactive"); require(incompatible.items.getStackInSlot(0).getCount() == 7 && incompatible.energy.stored() == 700, "Resources retained"); - require( - incompatible.saveWithFullMetadata(level.registryAccess()) - .getCompound("multiblock").getInt("version") == 999, - "Unknown manifest preserved"); + require(incompatible.saveWithFullMetadata(level.registryAccess()) + .getCompound("mmdlib_machine").getCompound("multiblock") + .getInt("version") == 999, "Unknown manifest preserved"); helper.succeed(); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java b/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java index bb9887ff..18538eac 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorElement.java @@ -244,13 +244,31 @@ private static CompoundTag row(RecipeProcessor processor, Level level, result.putString("reason", match.reason().name()); result.putString("group", match.group() == null ? "" : match.group()); result.putInt("time", recipe.time()); + long energy = recipe.inputs().values().stream().flatMap(List::stream) + .filter(MachineIngredient.EnergyPart.class::isInstance) + .mapToLong(MachineIngredient::amount).sum(); + var costs = costs(recipe); + result.putLong("energy", energy); + var details = new ListTag(); + costs.stream().limit(32) + .forEach(cost -> details.add(StringTag.valueOf(cost))); + result.put("costs", details); + return result; + } + + static List costs( + zone.moddev.mc.lib.machine.recipe.MachineRecipe recipe) { var costs = new ArrayList(); - long energy = 0; for (var group : recipe.inputs().entrySet()) { for (var input : group.getValue()) { - if (input instanceof MachineIngredient.EnergyPart power) { - energy += power.amount(); + if (input instanceof MachineIngredient.EnergyPart) { + continue; } else if (input instanceof MachineIngredient.FluidPart fluid) { + if (!fluid.consumed()) { + costs.add(group.getKey() + ": " + fluid.amount() + + " mB required, 0 mB consumed"); + continue; + } // A return cannot be subtracted separately from several // input claims. // Describe complex recipes literally instead of inventing a @@ -283,12 +301,7 @@ private static CompoundTag row(RecipeProcessor processor, Level level, } } } - result.putLong("energy", energy); - var details = new ListTag(); - costs.stream().limit(32) - .forEach(cost -> details.add(StringTag.valueOf(cost))); - result.put("costs", details); - return result; + return List.copyOf(costs); } @Override diff --git a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java index 0b0ea41f..fa9a6dae 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java @@ -82,7 +82,23 @@ public MachineDefinition(ResourceLocation id, String checksum, String name, slots, groups, recipeType, processor, processorName, redstone, screen, null); } - /** Cube-front configuration retained for existing Java callers. */ + /** + * Original 2.0.x constructor, with automatic recipes and no optional + * visuals or ghosts. + */ + public MachineDefinition(ResourceLocation id, String checksum, String name, + float hardness, ResourceLocation mineable, boolean requiresTool, + Map textures, Power power, Fluids fluids, + String itemStorageId, List slots, List groups, + RecipeType recipeType, String processor, String processorName, + boolean redstone, Node screen) { + this(id, checksum, name, hardness, mineable, requiresTool, textures, + null, List.of(), List.of(), power, fluids, itemStorageId, slots, + groups, recipeType, processor, processorName, redstone, screen, + null, List.of()); + } + + /** Cube-front configuration with conditional parts and textures. */ public MachineDefinition(ResourceLocation id, String checksum, String name, float hardness, ResourceLocation mineable, boolean requiresTool, Map textures, List modelParts, diff --git a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java index f740f41c..6ee4972b 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java @@ -6,8 +6,7 @@ public final class MachineStorageKeys { public static final Set RESERVED = Set.of("automation", "features", "redstone_control", "redstone_mode", "id", "components", - "mmdlib_features", "mmdlib_machine", "mmdlib_ghost_items", "multiblock", - "multiblock_view"); + "mmdlib_features", "mmdlib_machine"); private MachineStorageKeys() { } diff --git a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java index a0a0fb69..4df6ea51 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java +++ b/src/main/java/zone/moddev/mc/lib/machine/multiblock/MultiblockInstance.java @@ -265,7 +265,7 @@ public void tick() { } } if (!matchesAll()) { - dismantle(Cause.RECOVERY); + dismantle(Cause.INVALIDATED); return; } for (var rule : structure.requirements()) { @@ -278,8 +278,11 @@ public void tick() { transition(Status.READY, "ready"); } + /** + * Last lifecycle validation result; capability queries never tick or + * dismantle. + */ public boolean ready() { - tick(); return committed && !changing && status == Status.READY && !owner.isRemoved(); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java index 72c389b1..3a12fc05 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java @@ -310,8 +310,11 @@ protected final void saveAdditional(CompoundTag tag, if (redstone != null) tag.put("redstone_control", redstone.save(registries)); saveMachineData(tag, registries); - if (multiblock() != null) - tag.put("multiblock", multiblock().save()); + if (multiblock() != null) { + var metadata = tag.getCompound(CLIENT_DATA); + metadata.put("multiblock", multiblock().save()); + tag.put(CLIENT_DATA, metadata); + } } @Override @@ -319,8 +322,8 @@ protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { if (FeatureSync.read(features, tag, registries)) { if (multiblock() != null && (level == null || level.isClientSide)) - multiblock() - .readClientState(tag.getCompound("multiblock_view")); + multiblock().readClientState(tag.getCompound(CLIENT_DATA) + .getCompound("multiblock_view")); var redstone = screenRedstone(); if (redstone != null && tag.contains("redstone_control", Tag.TAG_COMPOUND)) @@ -353,8 +356,12 @@ protected final void loadAdditional(CompoundTag tag, } } loadMachineData(tag, registries); - if (multiblock() != null) - multiblock().load(tag.getCompound("multiblock"), registries); + if (multiblock() != null) { + var metadata = tag.getCompound(CLIENT_DATA); + multiblock().load(metadata.contains("multiblock") + ? metadata.getCompound("multiblock") + : tag.getCompound("multiblock"), registries); + } } @Override @@ -367,7 +374,7 @@ public final CompoundTag getUpdateTag(HolderLookup.Provider registries) { saveClientData(clientData, registries); tag.put(CLIENT_DATA, clientData); if (multiblock() != null) - tag.put("multiblock_view", multiblock().clientState()); + clientData.put("multiblock_view", multiblock().clientState()); return tag; } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java index 8527483f..1fdd73cd 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java @@ -43,6 +43,9 @@ public DefinedMachineEntity(BlockEntityType type, BlockPos pos, MachineRecipeType recipes) { super(type, pos, state); this.definition = definition; + // Startup validates registered XML; keep direct Java construction + // equally strict. + MachineContent.validateResources(definition); var power = definition.power(); energy = power == null ? null @@ -112,9 +115,6 @@ public DefinedMachineEntity(BlockEntityType type, BlockPos pos, var samples = new zone.moddev.mc.lib.machine.inventory.GhostItemGroup( ghost.count()); if (ghost.defaultItem() != null) { - if (!BuiltInRegistries.ITEM.containsKey(ghost.defaultItem())) - throw new IllegalArgumentException( - "Unknown ghost default item " + ghost.defaultItem()); for (int i = 0; i < ghost.count(); i++) samples.set(i, new net.minecraft.world.item.ItemStack( BuiltInRegistries.ITEM.get(ghost.defaultItem()))); @@ -221,7 +221,9 @@ private void readGhosts(CompoundTag tag, @Override protected void saveMachineData(CompoundTag tag, net.minecraft.core.HolderLookup.Provider registries) { - saveGhosts(tag, registries); + var metadata = tag.getCompound("mmdlib_machine"); + saveGhosts(metadata, registries); + tag.put("mmdlib_machine", metadata); } @Override @@ -236,13 +238,21 @@ protected void loadMachineData(CompoundTag tag, for (int i = 0; i < ghost.count(); i++) ghosts.get(ghost.id()).set(i, sample); } - readGhosts(tag, registries); + var metadata = tag.getCompound("mmdlib_machine"); + // Pre-release ghost saves used a top-level key which could also be a + // consumer storage ID. + if (!metadata.contains("mmdlib_ghost_items") + && !definition.itemStorageId().equals("mmdlib_ghost_items") + && (definition.fluids() == null + || !definition.fluids().id().equals("mmdlib_ghost_items"))) + metadata = tag; + readGhosts(metadata, registries); // Migrate the earlier output selector, including an explicitly cleared // selection. var old = tag.getCompound("features") .getCompound(definition.processor()); - if (!tag.contains("mmdlib_ghost_items") && old.contains("selection") - && ghosts.size() == 1 + if (!metadata.contains("mmdlib_ghost_items") + && old.contains("selection") && ghosts.size() == 1 && ghosts.values().iterator().next().size() == 1) { var id = ResourceLocation .tryParse(old.getCompound("selection").getString("recipe")); diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java index e07f2323..415cb153 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java @@ -228,47 +228,58 @@ private static void validateRecipe(RecipeHolder holder) { private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) { for (var machine : MachineContent.definitions().values()) - if (machine.recipeType().id().equals(type.id())) { - var items = new HashSet(); - machine.groups().forEach(g -> items.add(g.id())); - var ghostItems = new HashSet(); - machine.ghosts().forEach(g -> ghostItems.add(g.id())); - var fluids = new HashSet(); - if (machine.fluids() != null) - machine.fluids().groups().forEach(g -> fluids.add(g.id())); - String energy = machine.power() == null - ? null - : machine.power().id(); - recipe.inputs().forEach((key, parts) -> { - if (!items.contains(key) && !ghostItems.contains(key) - && !fluids.contains(key) && !key.equals(energy)) - throw new IllegalArgumentException(machine.id() - + ": unknown recipe input group " + key); - parts.forEach(part -> { - boolean valid = part instanceof MachineIngredient.ItemPart - ? items.contains(key) || ghostItems.contains(key) - : part instanceof MachineIngredient.FluidPart - ? fluids.contains(key) - : part instanceof MachineIngredient.EnergyPart - && key.equals(energy); - if (!valid) - throw new IllegalArgumentException(machine.id() - + ": incompatible recipe input group " + key); - }); - }); - recipe.outputs().forEach((key, parts) -> { - if (!items.contains(key) && !fluids.contains(key)) - throw new IllegalArgumentException(machine.id() - + ": unknown recipe output group " + key); - parts.forEach(part -> { - if (!(part instanceof MachineProduct.ItemProduct - && items.contains(key)) - && !(part instanceof MachineProduct.FluidProduct - && fluids.contains(key))) - throw new IllegalArgumentException(machine.id() - + ": incompatible recipe output group " + key); - }); - }); - } + if (machine.recipeType().id().equals(type.id())) + validateGroups(machine, recipe); + } + + static void validateGroups( + zone.moddev.mc.lib.machine.definition.MachineDefinition machine, + MachineRecipe recipe) { + var items = new HashSet(); + machine.groups().forEach(g -> items.add(g.id())); + var ghostItems = new HashSet(); + machine.ghosts().forEach(g -> ghostItems.add(g.id())); + var fluids = new HashSet(); + if (machine.fluids() != null) + machine.fluids().groups().forEach(g -> fluids.add(g.id())); + String energy = machine.power() == null ? null : machine.power().id(); + recipe.inputs().forEach((key, parts) -> { + if (!items.contains(key) && !ghostItems.contains(key) + && !fluids.contains(key) && !key.equals(energy)) + throw new IllegalArgumentException( + machine.id() + ": unknown recipe input group " + key); + var ghost = machine.ghosts().stream() + .filter(g -> g.id().equals(key)).findFirst().orElse(null); + if (ghost != null && parts.stream() + .mapToLong(MachineIngredient::amount).sum() > ghost.count()) + throw new IllegalArgumentException( + machine.id() + ": ghost input group " + key + + " requires more samples than its " + ghost.count() + + " slots"); + parts.forEach(part -> { + boolean valid = part instanceof MachineIngredient.ItemPart + ? items.contains(key) || ghostItems.contains(key) + : part instanceof MachineIngredient.FluidPart + ? fluids.contains(key) + : part instanceof MachineIngredient.EnergyPart + && key.equals(energy); + if (!valid) + throw new IllegalArgumentException(machine.id() + + ": incompatible recipe input group " + key); + }); + }); + recipe.outputs().forEach((key, parts) -> { + if (!items.contains(key) && !fluids.contains(key)) + throw new IllegalArgumentException( + machine.id() + ": unknown recipe output group " + key); + parts.forEach(part -> { + if (!(part instanceof MachineProduct.ItemProduct + && items.contains(key)) + && !(part instanceof MachineProduct.FluidProduct + && fluids.contains(key))) + throw new IllegalArgumentException(machine.id() + + ": incompatible recipe output group " + key); + }); + }); } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java index ad0b0326..19c5e02d 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java @@ -177,15 +177,7 @@ private static void capabilities(RegisterCapabilitiesEvent event) { // Capabilities register after fluids, including fluids supplied by // other installed mods. for (var definition : definitions.values()) - if (definition.fluids() != null) - for (var tank : definition.fluids().tanks()) - if (tank.fluid() != null - && (!BuiltInRegistries.FLUID.containsKey(tank.fluid()) - || BuiltInRegistries.FLUID - .get(tank.fluid()) == Fluids.EMPTY)) - throw new IllegalArgumentException( - definition.id() + ": unknown or empty fluid " - + tank.fluid() + " in tank " + tank.id()); + validateResources(definition); for (var type : entities.values()) { event.registerBlockEntity(Capabilities.ItemHandler.BLOCK, type, (entity, @@ -207,4 +199,24 @@ private static void capabilities(RegisterCapabilitiesEvent event) { .mode(ResourceType.ENERGY, 0, side))); } } + static void validateResources(MachineDefinition definition) { + for (var ghost : definition.ghosts()) + if (ghost.defaultItem() != null && (!BuiltInRegistries.ITEM + .containsKey(ghost.defaultItem()) + || BuiltInRegistries.ITEM.get( + ghost.defaultItem()) == net.minecraft.world.item.Items.AIR)) + throw new IllegalArgumentException(definition.id() + + ": unknown or empty default item " + ghost.defaultItem() + + " in ghost group " + ghost.id()); + if (definition.fluids() != null) + for (var tank : definition.fluids().tanks()) + if (tank.fluid() != null + && (!BuiltInRegistries.FLUID.containsKey(tank.fluid()) + || BuiltInRegistries.FLUID + .get(tank.fluid()) == Fluids.EMPTY)) + throw new IllegalArgumentException( + definition.id() + ": unknown or empty fluid " + + tank.fluid() + " in tank " + tank.id()); + } + } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java index 74a900a0..ed7392d0 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MultiblockBlock.java @@ -8,6 +8,7 @@ import net.minecraft.util.RandomSource; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Explosion; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.EntityBlock; @@ -19,6 +20,7 @@ import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.phys.BlockHitResult; +import zone.moddev.mc.lib.common.runtime.DropsContents; import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; import zone.moddev.mc.lib.machine.multiblock.MultiblockController; import zone.moddev.mc.lib.machine.multiblock.MultiblockInstance; @@ -103,6 +105,18 @@ public BlockState playerWillDestroy(Level level, BlockPos pos, } return super.playerWillDestroy(level, pos, state, player); } + @Override + public void onBlockExploded(BlockState state, Level level, BlockPos pos, + Explosion explosion) { + if (!level.isClientSide) { + var instance = MultiblockMembers.resolve(level, pos); + if (instance != null) + instance.dismantle(MultiblockInstance.Cause.EXPLOSION); + } + if (level.getBlockState(pos).is(state.getBlock())) + super.onBlockExploded(state, level, pos, explosion); + } + @Override protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moved) { @@ -121,7 +135,8 @@ protected void onRemove(BlockState state, Level level, BlockPos pos, && controller.multiblock().contains(pos, link)) instance = controller.multiblock(); if (instance != null) - instance.dismantle(MultiblockInstance.Cause.RECOVERY); + instance.dismantle(MultiblockInstance.Cause.INVALIDATED); + DropsContents.onRemove(state, level, pos, replacement); } super.onRemove(state, level, pos, replacement, moved); } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java index 6a053094..92d74281 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/StorageGroups.java @@ -352,15 +352,10 @@ private void track(int index, MachineTx tx) { } private record EnergyStore(EnergyBuffer buffer) implements EnergyGroup { - @Override - public long revision() { - // Stored FE is the energy group's only recipe-matching input. - // Returning to the same - // amount leaves a failed match unchanged; a refill must bypass the - // idle retry delay. - return buffer.stored(); - } - + // Charging must not invalidate failed recipe searches every tick. Idle + // searches + // retry on the processor's bounded interval; running cycles check power + // each tick. @Override public Object statisticsResource() { return buffer; diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorCostTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorCostTests.java new file mode 100644 index 00000000..0794000e --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/gui/model/elements/RecipeSelectorCostTests.java @@ -0,0 +1,57 @@ +package zone.moddev.mc.lib.gui.model.elements; + +import java.util.List; +import java.util.Map; + +import net.minecraft.core.HolderSet; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; + +@GameTestHolder("mmdlib") +@PrefixGameTestTemplate(false) +public final class RecipeSelectorCostTests { + @GameTest(template = "empty") + public static void reusableFluidCostsNeverReportConsumption( + GameTestHelper h) { + var fluids = HolderSet.direct(Fluids.LAVA.builtInRegistryHolder()); + var reusable = new MachineIngredient.FluidPart(fluids, 1000, false); + var consumed = new MachineIngredient.FluidPart(fluids, 1000, true); + require( + RecipeSelectorElement + .costs(new MachineRecipe(Map.of("lava", List.of(reusable)), + Map.of(), 1)) + .equals(List.of("lava: 1000 mB required, 0 mB consumed")), + "Reusable fluid is required but not consumed"); + require( + RecipeSelectorElement + .costs(new MachineRecipe(Map.of("lava", List.of(consumed)), + Map.of(), 1)) + .equals(List.of("lava: 1000 mB required, 1000 mB consumed")), + "Ordinary fluid retains consumption"); + var returned = MachineRecipeBuilder.recipe(null).input("lava", consumed) + .outputFluid("lava", Fluids.LAVA, 250).time(1).build(); + require( + RecipeSelectorElement.costs(returned) + .equals(List.of("lava: 1000 mB required, 750 mB consumed")), + "Deterministic return subtracts once"); + require( + RecipeSelectorElement + .costs(new MachineRecipe( + Map.of("lava", List.of(reusable, consumed)), Map.of(), 1)) + .contains("lava: 1000 mB required, 0 mB consumed"), + "Mixed input groups retain reusable semantics"); + h.succeed(); + } + private static void require(boolean value, String message) { + if (!value) + throw new AssertionError(message); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java index b1f2fe1d..f4583c74 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ProcessorSmoke.java @@ -63,11 +63,14 @@ public ItemStack getStackInSlot(int slot) { return super.getStackInSlot(slot); } }; - var rig = new ProcessorRig(storage, false, false); + var rig = new ProcessorRig(storage, false, true); rig.ticks(level, 1); int firstSearch = reads[0]; require(firstSearch > 0, "Initial search examined the inventory"); - rig.ticks(level, 19); + for (int tick = 0; tick < 19; tick++) { + rig.energy.setStored(rig.energy.stored() + 10); + rig.ticks(level, 1); + } require(reads[0] == firstSearch, "An unchanged idle machine scanned recipes every tick"); storage.setStackInSlot(0, new ItemStack(Items.COBBLESTONE)); @@ -91,6 +94,13 @@ public ItemStack getStackInSlot(int slot) { require(wet.processor.status() == Status.RUNNING, "Fluid changes invalidate an idle search"); + var charging = ProcessorRig.crusher(1, 1000, 0); + charging.ticks(level, 1); + charging.energy.setStored(100_000); + charging.ticks(level, 20); + require(charging.processor.status() == Status.RUNNING, + "Periodic idle retry starts an otherwise ready recipe after charging"); + var manager = level.getRecipeManager(); var originals = new ArrayList>(manager.getRecipes()); try { diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java index 2ba6227e..3811d778 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -48,6 +48,38 @@ private String crusher() throws Exception { .of("src/companion/resources/mmdlib/machine/simple_crusher.xml")); } + @Test + void originalConstructorRetainsAutomaticCubeDefaults() throws Exception { + write(crusher()); + var d = MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")); + var compatible = new MachineDefinition(d.id(), d.checksum(), d.name(), + d.hardness(), d.mineable(), d.requiresTool(), d.textures(), + d.power(), d.fluids(), d.itemStorageId(), d.slots(), d.groups(), + d.recipeType(), d.processor(), d.processorName(), d.redstone(), + d.screen()); + assertEquals(d, compatible); + assertNull(compatible.baseModel()); + assertNull(compatible.recipeSelection()); + assertTrue(compatible.ghosts().isEmpty()); + } + + @Test + void newMetadataDoesNotReserveExistingStorageNames() throws Exception { + for (var key : List.of("multiblock", "multiblock_view", + "mmdlib_ghost_items")) { + write(crusher().replace("", + "")); + assertEquals(key, MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")).itemStorageId()); + write(crusher().replace("", "")); + assertEquals(key, MachineDefinitions.load(sources()) + .get(ResourceLocation.parse("test:crusher")).fluids().id()); + } + } + @Test void recipePresentationOnlyMarksGhostInputsSharedByItsMachines() throws Exception { From 1b8454f358e04ae32ebeb8ac1b58782b369708f1 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Thu, 12 Nov 2026 18:54:45 +0200 Subject: [PATCH 4/4] Keep storage-name fixture independent of input filtering --- .../java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java index 3811d778..b146cc0a 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -74,7 +74,7 @@ void newMetadataDoesNotReserveExistingStorageNames() throws Exception { .get(ResourceLocation.parse("test:crusher")).itemStorageId()); write(crusher().replace("", "")); + + "\">")); assertEquals(key, MachineDefinitions.load(sources()) .get(ResourceLocation.parse("test:crusher")).fluids().id()); }