From 963e0121dbfa5fc4590b4410b972efdc8feb4b9f Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sat, 7 Nov 2026 18:48:43 +0200 Subject: [PATCH 1/3] Add XML-defined machines, generated recipes and recipe bundles --- build.gradle | 11 +- docs/capabilities.md | 12 + docs/definition-loading.md | 8 + docs/gui-screens.md | 16 +- docs/machine-companion.md | 20 +- docs/mod-author-guide.md | 4 +- docs/xml-machines.md | 356 +++++++++++++ scripts/companion.gradle | 25 +- .../recipe/simple_crusher.json | 10 + .../recipe/simple_crushing/stone.json | 23 + .../mmdlib/machine/simple_crusher.xml | 18 + .../test/BasePoweredMachineTests.java | 84 +++- .../companion/test/CompanionClientSmoke.java | 10 + .../mc/companion/test/CompanionGameTests.java | 9 +- .../test/MachineRecipeLoadingTests.java | 217 ++++++++ .../test/ProcessorButtonClientSmoke.java | 33 +- .../test/SimpleCrusherClientSmoke.java | 281 +++++++++++ .../mc/companion/test/SimpleCrusherTests.java | 468 ++++++++++++++++++ src/main/java/zone/moddev/mc/lib/MMDLib.java | 3 +- .../api/automation/AutomationInventory.java | 5 - .../mc/lib/api/capabilities/FluidStorage.java | 3 + .../mc/lib/client/jei/MMDLibJeiPlugin.java | 8 + .../lib/client/jei/MachineRecipeCategory.java | 36 +- .../mc/lib/gui/client/MachineScreen.java | 3 +- .../moddev/mc/lib/gui/load/ScreenLoader.java | 38 +- .../moddev/mc/lib/gui/load/ScreenReader.java | 58 ++- .../mc/lib/gui/load/ScreenTemplates.java | 42 +- .../lib/gui/model/ProcessorButtonState.java | 18 +- .../mc/lib/gui/runtime/AutomationMenu.java | 4 +- .../mc/lib/gui/runtime/MachineMenu.java | 9 +- .../moddev/mc/lib/gui/runtime/ScreenHost.java | 5 + .../runtime/ScreenInventoryValidation.java | 13 +- .../mc/lib/gui/runtime/ScreenRuntime.java | 18 +- .../mc/lib/gui/runtime/ScreenSnapshots.java | 5 +- .../machine/definition/MachineDefinition.java | 59 +++ .../definition/MachineDefinitions.java | 419 ++++++++++++++++ .../definition/MachineScreenGenerators.java | 165 ++++++ .../machine/neoforge/BasePoweredMachine.java | 10 +- .../machine/neoforge/DefinedMachineBlock.java | 116 +++++ .../neoforge/DefinedMachineEntity.java | 139 ++++++ .../neoforge/GeneratedMachineRecipes.java | 233 +++++++++ .../lib/machine/neoforge/MachineContent.java | 183 +++++++ .../neoforge/MachineInventoryLifecycle.java | 34 +- .../machine/neoforge/MachineRecipeBundle.java | 94 ++++ .../machine/neoforge/MachineRecoveryItem.java | 76 +++ .../machine/neoforge/MachineResourcePack.java | 189 +++++++ .../machine/neoforge/MachineResources.java | 3 +- .../machine/neoforge/RecipeInputFilters.java | 81 ++- .../lib/machine/neoforge/StorageGroups.java | 1 - .../generation/GeneratorProperties.java | 65 +++ .../generation/MachineRecipeGenerator.java | 6 + .../generation/MachineRecipeOutput.java | 11 + .../generation/OreToIngotGenerator.java | 181 +++++++ .../generation/RecipeGenerationContext.java | 20 + .../machine/recipe/generation/ReloadTags.java | 45 ++ .../resources/assets/mmdlib/lang/en_us.json | 2 +- .../mmdlib/machine/base_powered_machine.xml | 13 + src/main/resources/mmdlib/schema/screen.xsd | 2 + .../mmdlib/screen/base_powered_machine.xml | 2 +- .../lib/smoke/InventoryArchitectureSmoke.java | 62 ++- .../mc/lib/gui/ProcessorButtonTest.java | 12 +- .../mc/lib/gui/ScreenFillGeneratorTest.java | 164 ++++++ .../lib/machine/MachineDefinitionsTest.java | 186 +++++++ 63 files changed, 4319 insertions(+), 127 deletions(-) create mode 100644 docs/xml-machines.md create mode 100644 src/companion/resources/data/mmdlib_companion/recipe/simple_crusher.json create mode 100644 src/companion/resources/data/mmdlib_companion/recipe/simple_crushing/stone.json create mode 100644 src/companion/resources/mmdlib/machine/simple_crusher.xml create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherTests.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeBundle.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeGenerator.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeOutput.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/ReloadTags.java create mode 100644 src/main/resources/mmdlib/machine/base_powered_machine.xml create mode 100644 src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java create mode 100644 src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java diff --git a/build.gradle b/build.gradle index 406c15a9..de2018a4 100644 --- a/build.gradle +++ b/build.gradle @@ -206,10 +206,13 @@ tasks.register('format') { if (!jar.isFile()) throw new GradleException("google-java-format not found at ${jar}; see build.gradle") // Imports are left in place here and grouped by the script below instead. - providers.exec { - commandLine([java, '-jar', jar.absolutePath, '--skip-sorting-imports', '--replace'] + - sources.files.collect { it.absolutePath }) - }.result.get() + // Keep each invocation below Windows' command-line length limit as the library grows. + sources.files.toList().collate(40).each { batch -> + providers.exec { + commandLine([java, '-jar', jar.absolutePath, '--skip-sorting-imports', '--replace'] + + batch.collect { it.absolutePath }) + }.result.get() + } def grouping = providers.exec { commandLine python, file('scripts/group_imports.py').absolutePath, file('src').absolutePath } diff --git a/docs/capabilities.md b/docs/capabilities.md index 4e972fb6..0e759766 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -80,6 +80,15 @@ index, or through the explicit `deserializeNBT(registries, tag, IntUnaryOperator overload. Select that mapping by your machine's save version; it applies only to legacy entries. Keep slot/tank IDs stable when reordering a definition. +For `BasePoweredMachine`, override `protected IntUnaryOperator legacySlots(String key, +CompoundTag tag)`. The key identifies the storage and `tag` is the complete world-save tag, so +the hook can read your saved version before `loadMachineData` runs. It defaults to identity +and applies to both items and fluids before features restore their reservations. Entries with +stable IDs bypass the mapping. Direct lifecycle users can pass the same resolver to `load`. + +Storage save keys cannot use `automation`, `features`, `redstone_control`, `redstone_mode`, +`id`, `components`, `mmdlib_features`, or `mmdlib_machine`; those belong to persistence or sync. + Storage keeps its configured shape. Removed slots/tanks, overflow after reducing limits/capacity, incompatible colliding contents and undecodable entries are retained in raw `Recovery` data, instead of being discarded. Subsequent loads retry recovery entries, allowing returning IDs or @@ -99,6 +108,9 @@ input rules are installed by [MachineResources](inventory-automation.md#shared-m They supplement existing filters and reservations. Machine products use `insertResult` for items or `fillResult` for fluids to bypass external admission rules; fluid products still obey the tank's structural filter and capacity. Restoring a saved item explicitly can use `restoreStack`. +Recipe-linked filters index accepted items and fluids per recipe-manager snapshot and rebuild +after reload or client recipe synchronization. Component-sensitive custom ingredients still test +the actual stack on every probe; item identity alone cannot decide those matches. ## Example diff --git a/docs/definition-loading.md b/docs/definition-loading.md index 449e56eb..f4ac6089 100644 --- a/docs/definition-loading.md +++ b/docs/definition-loading.md @@ -11,6 +11,7 @@ src/main/resources/mmdlib/ fluid/coolant.json extension/armor_effects.json material/copper.json + machine/simple_crusher.xml ``` IDs use the owning mod's namespace and the relative path below the section, @@ -20,6 +21,13 @@ root-level directory belongs to the first mod declared in its metadata. Extensio files also have file IDs, but their callable handler IDs come from the explicit `id` fields in their `events` array. +Machine XML uses the same namespace/path rule, without `.xml`. It is loaded after +the other startup definitions and composed with the screen templates. See +[XML machines](xml-machines.md) for inherited storage, generated screen fills, +recipe generators and the complete SimpleCrusher example. The generic machine +runtime registers concrete definitions; abstract definitions provide reusable +configuration only. + The existing baseline under `data/mmdlib/{item,block,fluid}` remains loaded through `ModuleCatalog.bundled()`. The startup scanner does not scan that catalog again or reinterpret unrelated mods' data directories. `ModuleCatalog.load(packRoot)` diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 0446db6d..ffcf02fd 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -143,6 +143,8 @@ Repeated references to one getter are evaluated once per machine per tick, share Navigation is checked on the server and its selection is synced back to the client. Opening also compares a screen-model fingerprint, so differing XML definitions fail explicitly instead of assigning values or actions to the wrong elements. +`ScreenHost.screenDefinitionFingerprint()` can contribute additional startup configuration; +XML machines supply their resolved definition CRC, including inherited storage settings. `./gradlew check` includes numeric protocol and layout regression checks. The isolated graphical smoke run is `xvfb-run -a ./gradlew runClient --offline -I scripts/gui-smoke.init.gradle`; repeat @@ -479,6 +481,12 @@ Inheritance does not replace the type-ID screen lookup described above. the named placeholder's contents; an empty fill removes its default content. - An unfilled optional placeholder uses its children, or nothing when it has none. A required placeholder must be filled and cannot also have default content. +- A placeholder may instead supply `generator="namespace:id"` and generator options. + An explicit fill replaces that generator, even when empty; a fill can choose its own + generator. Generated fills cannot also contain literal children. Generators return normal + screen elements, which undergo the same expanded validation. See + [machine screen generators](xml-machines.md#generated-screen-fills) for the available + machine-aware generators and registration API. - Chains can introduce placeholders and override earlier fills. Replacing an outer placeholder removes its nested extension points; also filling a removed inner point is an error. - Unknown bases/fills, duplicate placeholders/fills and cycles fail at load. Expanded slots, IDs, @@ -545,8 +553,11 @@ Icons show the current state, not the next action: | Processor state | Icon | Tooltip lines / click | |---|---|---| | Manually paused | Pause | `Paused` / `Click to unpause` | -| Not paused and processing | Stop | `Working` / `Click to stop and clear reservations` | -| Not paused and not processing | Play | `Idle` / `Click to pause` | +| Processing | Stop | `Working` / `Click to pause and release reservations` / `Progress is kept` | +| Waiting for energy | Stop | `Needs power` / same pause action | +| Cannot complete the recipe | Stop | `Recipe cannot finish` / same pause action | +| Held by the host | Stop | `Held by machine controls` / same pause action | +| Idle | Play | `Idle` / `Click to pause` | Tooltips are compact and multiline. Manual pause releases reservations, keeps resumable progress, marks the machine changed and synchronizes immediately. Redstone/host holds preserve reservations @@ -870,6 +881,7 @@ When JEI is installed: offers, each naming the `id` of the element that shows it (a ``, ``, ...). While a group is selected in the side-configuration view, that element's background is highlighted when it is visible on the screen, so the player sees which slots or tank they are changing. + Highlight targets are optional: missing elements are skipped and do not prevent the menu opening. Numbering is unaffected: every face still shows every slot, and a group's setting only allows or refuses operations on its own slots. - **Block-entity registration is a hybrid.** For a data-defined machine, MMDLib validates the diff --git a/docs/machine-companion.md b/docs/machine-companion.md index 83fa0e7f..78b8dbf0 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -1,6 +1,6 @@ # Machine companion -The optional `mmdlib_companion` mod contains five playable machine examples. +The optional `mmdlib_companion` mod contains six playable machine examples. 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. @@ -38,12 +38,23 @@ those mods' login payloads; real client smoke runs include them. | `dual_crusher` | Two independently pausable processors share the same input and output slots | | `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 | 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 200 ticks and 4,000 FE. The dual crusher runs the same recipes in both lanes when there are enough inputs and output space. +SimpleCrusher converts one ore to two ingots. Its `.10` hardness scaling increases +the base 100 ticks / 2,000 FE to 130 ticks / 2,600 FE for regular iron ore. +Its recipe generator joins `c:ores/` and `c:ingots/` tags, including +metals added by other mods. It has no companion Java block or entity class. See +[XML machines](xml-machines.md) for the complete definition and generator contract. +For focused verification of this example, run `./gradlew runSimpleCrusherClientSmoke`. +It opens the generated screen, checks the model and synchronized recipes, reloads +server resources, and saves before/after screenshots under +`build/simple-crusher-client-smoke/screenshots/`. + The Crusher's two outputs share one inventory and colour/side configuration. For testing, both allow insertion and extraction, so you can manually fill them with any item; reservations still protect promised output space. New Crushers default this group to input/output on all faces. Outputs may @@ -55,8 +66,9 @@ starting empty. The Dual Crusher keeps its existing shared single-output layout. The bottom of each processor panel has a compact, right-aligned row of icon buttons above the inventory, with thin dark borders and flat grey backgrounds. The generic processor button shows its **current** state: pause when manually paused, stop while -working, play while idle. Click to unpause, stop and release reservations, or pause idle work, -respectively. Resumable progress is retained. The Dual Crusher targets each lane explicitly; +working or held, play while idle. Tooltips distinguish waiting for power, blocked completion and +host holds. Click to unpause, pause and release reservations, or pause idle work, respectively. +The pause tooltip explicitly says progress is kept. The Dual Crusher targets each lane explicitly; pausing one releases only its share. Single-processor machines inherit a keyless button from the base. Redstone face settings control whether processing may run. A redstone hold keeps progress and @@ -70,7 +82,7 @@ Every screen inherits the yellow Info popout from `mmdlib:base_powered_machine`. `{machineId}.info`, such as `mmdlib_companion:transmuter.info`, plus dynamic warnings from the base entity. The base reports processor power stalls, blocked finishes and host holds; Crusher can also report its configured energy reserve. Click the icon to close the drawer; scroll longer text. -All five screens place a vertical battery on the right using ``. +All six screens place a vertical battery on the right using ``. Its red charge bars fill upwards inside a slim casing. The energy and fluid gauges share matching edges and medium-grey empty backgrounds, darker than slots. Energy bars are thin with a soft reflection; cylindrical glass shading covers only the fluid. diff --git a/docs/mod-author-guide.md b/docs/mod-author-guide.md index 6d296282..ee052a2c 100644 --- a/docs/mod-author-guide.md +++ b/docs/mod-author-guide.md @@ -15,7 +15,9 @@ available for mods that own their loading stage. registries, including ammunition, armor, stateful blocks, fluid families, and client hooks. MMDLib uses it for its [playable vanilla extensions](vanilla-extensions.md). Mods must still supply their assets/recipes, or a generator appropriate to their -forms. Machines and custom world entities use their dedicated typed factory APIs. +forms. Powered item-processing machines can use [XML definitions](xml-machines.md), +including inherited storage, generated screens and dynamic recipes. More specialized +machines and custom world entities use their dedicated typed factory APIs. The manual wiring below remains an alternative; do not queue objects twice. ## 1. Lay out the data diff --git a/docs/xml-machines.md b/docs/xml-machines.md new file mode 100644 index 00000000..a07a937f --- /dev/null +++ b/docs/xml-machines.md @@ -0,0 +1,356 @@ +# XML machines + +An installed mod can declare a powered item-processing machine in +`src/main/resources/mmdlib/machine/.xml`. Its ID is `:`. +MMDLib registers the block, item, block-entity type, declared recipe type and +serializer, capabilities, screen and JEI catalyst. All instances use the shared +`DefinedMachineBlock` and `DefinedMachineEntity`; no machine-specific Java class +is needed. + +Definitions load at startup on both sides. Changing a machine's storage, screen, +appearance or recipe generator configuration requires a restart. Recipe JSON and +tag changes take effect on server `/reload`, including regeneration and client +synchronization. Both sides must have the same mod and startup definitions. +Ordinary world datapacks cannot register new machine blocks. + +Menu opening checks a CRC32 of the resolved machine XML together with the screen +fingerprint. Inherited machine settings are included, so changing the base's energy +configuration or a slot's role/limit is detected even if the screen looks identical. +XML attribute order, comments, whitespace and local source paths do not affect the CRC. +Different definitions are rejected before constructing the client menu. + +## SimpleCrusher + +The playable example is +[`simple_crusher.xml`](../src/companion/resources/mmdlib/machine/simple_crusher.xml): + +```xml + + + + + + + + + + + + + +``` + +It accepts ores with matching `c:ores/` and `c:ingots/` tags and +produces two ingots. Its `.10` hardness ratio scales the defaults of 100 processing +ticks and 2,000 FE total: regular iron ore costs 130 ticks and 2,600 FE. +Omitting that option uses the unscaled defaults. Recipe admission +filters reject unrelated input items. Output slots refuse insertion by players +and automation, and the processor reserves inputs and output space using the +normal machine recipe lifecycle. One initial tick selects/reserves the recipe +before processing begins. + +The example reuses vanilla textures; your own textures can use names such as +`example:block/crusher_front`, backed by +`assets/example/textures/block/crusher_front.png`. The cube preset generates +blockstates, block/item models, English labels, mining tags and a self-drop loot +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. + +## Base machine and storage + +The library's separate machine definition +[`base_powered_machine.xml`](../src/main/resources/mmdlib/machine/base_powered_machine.xml) +provides: + +```xml + + + + + + + + + + + + +``` + +Machine and screen definitions have separate namespaces of IDs, so these two +`mmdlib:base_powered_machine` resources serve different purposes. The machine +inherits common configuration; its screen inherits the reusable visual template. +Abstract machines register nothing. Attributes and children merge through the +inheritance chain by element name and `id` (or `name` for fills). A child can +override just the energy capacity while retaining the base rates. Generator and +fill elements replace their inherited counterparts completely. + +`` creates stable member IDs `input/0` +and `input/1`, plus a recipe/automation group named `input`. Growing the count +retains those identities. `` creates one member +with exactly that ID; add it to an explicit group: + +```xml + + + +``` + +Both forms can coexist. Each item member belongs to exactly one group. Explicit +groups can configure the mode/color of an automatic group without changing its +membership. Roles are `input`, `output`, `storage`, and `manual`; automatic group +modes are respectively `input`, `output`, `both`, and `disabled`. A group cannot +grant automation permissions that its members' roles prohibit. Slot `limit` +defaults to 64. There must be 1–54 item slots overall. + +The initial runtime supports one item storage, the inherited energy buffer +named `energy`, one recipe type and one processor per machine. Energy automation +uses the `energy/input` group. Item storage IDs `energy`, `features`, `automation`, +`redstone_control`, `redstone_mode`, `id`, `components`, `mmdlib_features`, and +`mmdlib_machine` are reserved. Input-mode item groups are automatically linked to +the processor's recipes; an explicit `filter-inputs` list must name those groups. +Processor `name` optionally overrides the displayed machine name. + +Stable storage IDs, processing reservations and side settings survive saves. +Displaced resources use `mmdlib:machine_recovery_capsule`; use a capsule on an XML +machine to restore compatible items and energy. Overflow and unknown resource +entries remain in the capsule. + +## Generated screen fills + +The base screen supplies the power gauge, player inventory, processor button, +side/redstone controls, Info and Statistics. Its machine-specific region is: + +```xml + +``` + +`mmdlib:processing` places input groups on the left, a progress arrow in the +middle, output groups on the right, and storage/manual groups below. Groups and +their members retain definition order within each category. Slot IDs and group +highlights are derived automatically. The processing cluster is centered in the +area before the base template's energy gauge. Generated processor arrows open +their recipe type in JEI when clicked. SimpleCrusher needs no `` or +`` of its own. + +A machine can replace that region: + +```xml + + + + + + + + + + +``` + +This screen inherits `extends` from the machine base. To replace the generator +instead, use ``. An explicit +fill always replaces the template's default, including an empty fill. The nearest +fill in the inheritance chain wins. A generator cannot have literal children, +and required placeholders cannot also have defaults. + +Custom screen templates can use these fragment generators on their placeholders: + +| Generator | Options | Content | +| --- | --- | --- | +| `mmdlib:processing` | None | Complete processor region, excluding inherited frame/controls | +| `mmdlib:resource-groups` | `role="input\|output\|storage"`, optional `columns="1..9"` (default 3) | Slots for matching groups; storage includes manual groups | +| `mmdlib:processors` | None | The single processor's progress arrow | + +Register custom namespaced builders with `MachineScreenGenerators.register` +before definitions load. A builder receives the resolved `MachineDefinition` +and the chosen placeholder/fill `ScreenReader.Node`, including custom attributes. +It returns ordinary screen nodes. The normal schema, layout and getter checks +run after expansion, followed by validation of slot indices and processor keys +against the machine. Literal fills use the existing screen XML syntax; a +`` screen shorthand is not part of this implementation. + +## Recipe generators and authored recipes + +`` names a public, concrete class implementing +`MachineRecipeGenerator`, with a public no-argument constructor. All other +attributes become immutable custom properties. The generator validates and +interprets its own options; there is no global list of recipe generator options. + +```java +public void generate(RecipeGenerationContext context, MachineRecipeOutput output) { + var p = context.properties(); + p.requireOnly("input-group", "output-group", "energy-group", "time", "energy"); + output.accept( + ResourceLocation.fromNamespaceAndPath(context.recipeType().id().getNamespace(), + "generated/my_recipe"), + MachineRecipeBuilder.recipe(context.recipeType()) + .inputItems(p.requiredString("input-group"), Ingredient.of(Items.COBBLESTONE), 1) + .inputEnergy(p.requiredString("energy-group"), p.requiredLong("energy", 1, Long.MAX_VALUE)) + .outputItem(p.requiredString("output-group"), new ItemStack(Items.GRAVEL)) + .time(p.requiredInt("time", 1, Integer.MAX_VALUE)) + .build()); +} +``` + +The context supplies the declared `recipeType()`, registry lookup `registries()`, +staged item/block `tags()` for the current reload, authored `recipes()`, and `properties()`. +Use `tags().ingredient(tagId)` to build an ingredient from that reload's item tag +members rather than consulting previously bound tags. It has no live block entity +or inventory. The resulting recipe names logical input, energy and output groups; +the shared processor handles selection, reservations, progress and completion. + +Generation runs once per declared recipe type on initial server load and every +reload, after vanilla recipes and staged tags are ready. A fresh generator +instance builds a fresh recipe set. Emitted IDs must use the recipe type's +namespace. Outputs are codec-validated and copied, and group resource kinds are +checked against every XML machine using the type. Duplicate generated IDs fail +the reload. An authored recipe with the same ID replaces the generated one. +Generator failure prevents publishing the candidate recipe manager; a successful +reload synchronizes ordinary machine recipes to clients. + +`OreToIngotGenerator` matches `c:ores/` with `c:ingots/`, including +metals from other installed mods. Empty/unmatched tags are skipped. It consumes +one ore and produces two ingots; these are generator defaults, independent of +inventory size. There are no `input-count` or `output-count` properties. + +All properties are optional; the declaration needs only `class`. Code defaults are: + +| Property | Default | Meaning | +| --- | --- | --- | +| `input-group` | `input` | Item group consumed by the recipe | +| `output-group` | `output` | Item group receiving the ingots | +| `energy-group` | `energy` | Energy group supplying the recipe cost | +| `time` | `100` | Base processing ticks, positive integer | +| `energy` | `2000` | Base total FE cost, positive integer | +| `preferred-mods` | `minecraft;mmdlib` | Ordered, semicolon-separated output namespaces | +| `tool-level-ratio` | `0.0` | Additional cost multiplier per required tool level | +| `hardness-level-ratio` | `0.0` | Additional cost multiplier per block hardness unit | + +For multiple ingot choices, the first available preferred namespace wins, then +registry ID order breaks ties. Unlisted namespaces follow all preferred ones. +For example, `preferred-mods="my_metals;minecraft;mmdlib"` prefers your mod's ingot. +Empty entries, duplicate namespaces, invalid numbers and overflowing costs fail +with the definition's source location. + +Both time and energy use the same multiplier, rounding each final amount up: + +```text +multiplier = 1 + toolLevel * tool-level-ratio + hardness * hardness-level-ratio +ticks = ceil(time * multiplier) +total FE = ceil(energy * multiplier) +``` + +`.10` means an additional 10% of the base cost per level/unit, without compounding. +Set these options on ``, not ``: + +```xml + +``` + +For example, tool level 2 with `tool-level-ratio=".10"` and no hardness scaling +costs 120 ticks and 2,400 FE using the defaults. Both ratios at `0.0` leave costs unchanged. +Calculations use `double`. Values within four floating-point steps of an integer +are snapped to that integer before rounding up, so `100 * 1.1` costs 110 rather +than 111 ticks. Non-finite or out-of-range results are rejected; unchanged costs +retain their exact integer value. + +Ratios must be finite and nonnegative. Tool levels use the current reload's block +tags: wood/no tier requirement = 0, stone = 1, iron = 2, diamond = 3, netherite = 4, +and blocks rejecting netherite = 5. The generator reads `needs_*_tool` and +`incorrect_for_*_tool` tags, rather than stale globally bound tags. These are vanilla +tier bands; custom tool tiers need to express their requirements in those tags. +Hardness is the block's default destruction time, without a world position or +state-specific overrides. Non-block items use level/hardness zero; when scaling +is enabled, unbreakable blocks are skipped. + +With both ratios zero, recipe IDs are +`:generated//ores/`; for example, +`mmdlib_companion:generated/simple_crushing/ores/iron`. Removing a tag on reload +removes its generated recipe. Enabling either ratio creates one recipe per ore +item, appending `//` to the ID. Regular and deepslate ores +can then have different costs, and an authored override can target either variant. + +You may omit `` and use only JSON, or combine both. An authored recipe +uses the declared type's normal codec: + +```json +{ + "type": "mmdlib_companion:simple_crushing", + "inputs": { + "input": [{"items": {"item": "minecraft:cobblestone"}}], + "energy": [{"energy": 2000}] + }, + "outputs": {"output": [{"item": {"id": "minecraft:gravel", "count": 1}}]}, + "time": 100 +} +``` + +### Several recipes in one JSON file + +Use `mmdlib:machine_recipes` with a named `recipes` object in the usual recipe +directory. Each entry contains a complete machine recipe, including its `type`: + +```json +{ + "type": "mmdlib:machine_recipes", + "recipes": { + "cobblestone": { + "type": "mmdlib_companion:simple_crushing", + "inputs": { + "input": [{"items": {"item": "minecraft:cobblestone"}}], + "energy": [{"energy": 2000}] + }, + "outputs": {"output": [{"item": {"id": "minecraft:gravel"}}]}, + "time": 100 + }, + "gravel": { + "type": "mmdlib_companion:simple_crushing", + "inputs": { + "input": [{"items": {"item": "minecraft:gravel"}}], + "energy": [{"energy": 2000}] + }, + "outputs": {"output": [{"item": {"id": "minecraft:sand"}}]}, + "time": 100 + } + } +} +``` + +The companion ships this as `data/mmdlib_companion/recipe/simple_crushing/stone.json`. +Its recipe IDs are `mmdlib_companion:simple_crushing/stone/cobblestone` and +`mmdlib_companion:simple_crushing/stone/gravel`. Entry names are resource paths +(lowercase letters, digits, `_`, `-`, `.`, `/`), not array positions; reordering +entries preserves IDs. Renaming an entry changes its ID. + +Entries may use different machine recipe types, including types registered in Java. +Nested bundles and vanilla crafting/smelting recipes are not supported inside a bundle. +NeoForge `neoforge:conditions` work on both the whole file and individual entries. +Only the resulting individual recipes are published, synchronized and shown in JEI. + +Datapack priority replaces the entire file. An empty `recipes` object removes its +entries. Two authored definitions producing the same final ID are an error; this +includes a bundle entry colliding with a standalone file. Authored entries continue +to override generated recipes with the same ID. Bundles are expanded before recipe +generators run, so generators see the individual authored recipes. + +Both standalone and bundled recipes are checked against every XML machine using +their type. Unknown groups and wrong resource kinds fail the candidate reload with +the recipe ID and machine ID. This validation also runs for types without a recipe +generator. A rejected reload leaves the previously active recipes intact. + +Multiple machines can use a type declared by one XML definition. Repeated +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 powered item processing and cube appearances. XML fluid +storage, 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 bcd58c75..56625617 100644 --- a/scripts/companion.gradle +++ b/scripts/companion.gradle @@ -57,6 +57,8 @@ neoForge { sourceSet = sourceSets.companionTest loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] systemProperty 'mmdlib.companionSmoke', 'true' + if (project.hasProperty('processorButtonSmokeOnly')) + systemProperty 'mmdlib.processorButtonSmokeOnly', 'true' programArguments.addAll '--width', '1280', '--height', '960' } companionGameTestServer { @@ -66,12 +68,33 @@ neoForge { loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] systemProperty 'neoforge.enabledGameTestNamespaces', 'mmdlib_companion' } + simpleCrusherClientSmoke { + client() + gameDirectory = layout.buildDirectory.dir('simple-crusher-client-smoke') + sourceSet = sourceSets.companionTest + loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] + systemProperty 'mmdlib.simpleCrusherSmoke', 'true' + programArguments.addAll '--width', '1280', '--height', '960' + } + } +} + +tasks.matching { it.name == 'runSimpleCrusherClientSmoke' }.configureEach { + doFirst { + def smokeDir = layout.buildDirectory.dir('simple-crusher-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('simple-crusher-client-smoke/logs/latest.log').get().asFile + if (!log.exists() || !log.text.contains('SIMPLE_CRUSHER_CLIENT_PASS') || log.text.contains('SIMPLE_CRUSHER_CLIENT_FAIL')) + throw new GradleException('SimpleCrusher client smoke failed; inspect ' + log) } } def companionJar = tasks.register('companionJar', Jar) { group = 'build' - description = 'Builds the optional four-machine companion mod.' + description = 'Builds the optional six-machine companion mod.' archiveBaseName = 'mmdlib-companion' destinationDirectory = layout.buildDirectory.dir('companion/libs') from sourceSets.companion.output diff --git a/src/companion/resources/data/mmdlib_companion/recipe/simple_crusher.json b/src/companion/resources/data/mmdlib_companion/recipe/simple_crusher.json new file mode 100644 index 00000000..f50224d1 --- /dev/null +++ b/src/companion/resources/data/mmdlib_companion/recipe/simple_crusher.json @@ -0,0 +1,10 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": ["III", "IFI", "IRI"], + "key": { + "I": {"item": "minecraft:iron_ingot"}, + "F": {"item": "minecraft:furnace"}, + "R": {"item": "minecraft:redstone"} + }, + "result": {"id": "mmdlib_companion:simple_crusher", "count": 1} +} diff --git a/src/companion/resources/data/mmdlib_companion/recipe/simple_crushing/stone.json b/src/companion/resources/data/mmdlib_companion/recipe/simple_crushing/stone.json new file mode 100644 index 00000000..dc42f6e2 --- /dev/null +++ b/src/companion/resources/data/mmdlib_companion/recipe/simple_crushing/stone.json @@ -0,0 +1,23 @@ +{ + "type": "mmdlib:machine_recipes", + "recipes": { + "cobblestone": { + "type": "mmdlib_companion:simple_crushing", + "inputs": { + "input": [{"items": {"item": "minecraft:cobblestone"}}], + "energy": [{"energy": 2000}] + }, + "outputs": {"output": [{"item": {"id": "minecraft:gravel"}}]}, + "time": 100 + }, + "gravel": { + "type": "mmdlib_companion:simple_crushing", + "inputs": { + "input": [{"items": {"item": "minecraft:gravel"}}], + "energy": [{"energy": 2000}] + }, + "outputs": {"output": [{"item": {"id": "minecraft:sand"}}]}, + "time": 100 + } + } +} diff --git a/src/companion/resources/mmdlib/machine/simple_crusher.xml b/src/companion/resources/mmdlib/machine/simple_crusher.xml new file mode 100644 index 00000000..add6d134 --- /dev/null +++ b/src/companion/resources/mmdlib/machine/simple_crusher.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java index 394cedff..00fc7f49 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BasePoweredMachineTests.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.function.IntUnaryOperator; import com.mojang.serialization.Codec; @@ -16,7 +17,9 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; +import net.minecraft.world.level.material.Fluids; +import net.neoforged.neoforge.fluids.FluidStack; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; @@ -25,6 +28,7 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.api.redstone.RedstoneControl; +import zone.moddev.mc.lib.gui.model.ProcessorButtonState; import zone.moddev.mc.lib.machine.feature.*; import zone.moddev.mc.lib.machine.neoforge.*; @@ -35,6 +39,63 @@ private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); } + @GameTest(template = "empty") + public static void legacyMappingsRunBeforeFeaturesAndRespectStableIds(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + var probe = new Probe(helper, 1); + probe.metadata = 1; + probe.featureValue = 7; + var saved = probe.saveWithoutMetadata(registries); + var oldItems = new ItemStorage(2); + oldItems.restoreStack(0, new ItemStack(Items.DIAMOND, 3)); + oldItems.restoreStack(1, new ItemStack(Items.EMERALD, 4)); + var oldTanks = new FluidStorage(); + oldTanks.addTank(2000, Role.STORAGE); + oldTanks.addTank(2000, Role.STORAGE); + oldTanks.restoreFluid(0, new FluidStack(Fluids.WATER, 500)); + oldTanks.restoreFluid(1, new FluidStack(Fluids.LAVA, 700)); + saved.put("items", oldItems.serializeNBT(registries)); + saved.put("fluids", oldTanks.serializeNBT(registries)); + for (int pass = 0; pass < 2; pass++) { + var restored = new Probe(helper, 1); + restored.loadWithComponents(saved, registries); + require( + restored.items.getStackInSlot(0).is(Items.EMERALD) + && restored.items.getStackInSlot(1).getCount() == 3 + && restored.tanks.getFluidInTank(0).is(Fluids.LAVA) + && restored.tanks.getFluidInTank(1).getAmount() == 500, + "Legacy indexes remap once; subsequent identified entries retain their identities"); + require( + restored.itemAtRestore.is(Items.EMERALD) && restored.featureValue == 7, + "Features see migrated storage before custom world metadata is restored"); + saved = restored.saveWithoutMetadata(registries); + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void lifecycleRejectsReservedSaveKeys(GameTestHelper helper) { + var probe = new Probe(helper, 1); + for (var key : + List.of( + "automation", + "features", + "redstone_control", + "redstone_mode", + "id", + "components", + "mmdlib_features", + "mmdlib_machine")) { + try { + probe.lifecycle.items(key, new ItemStorage(1)); + throw new AssertionError("Reserved storage key accepted: " + key); + } catch (IllegalArgumentException expected) { + require(expected.getMessage().contains(key), "Diagnostic names the reserved key"); + } + } + helper.succeed(); + } + @GameTest(template = "empty") public static void ownedPowerSurvivesEvenWhenCustomLifecycleOmitsIt(GameTestHelper helper) { var registries = helper.getLevel().registryAccess(); @@ -140,6 +201,11 @@ public static void baseWarningsDescribeProcessorsAndRespectPausePolicy(GameTestH 0, Optional.empty(), Optional.of(status))); + require( + ProcessorButtonState.of(first) + .name() + .equals(status == RecipeProcessor.Status.RUNNING ? "PROCESSING" : status.name()), + "Processor button distinguishes every current state, including automatic holds"); var key = switch (status) { case STALLED -> "screen.mmdlib.machine.stalled"; @@ -180,7 +246,9 @@ public static void baseWarningsDescribeProcessorsAndRespectPausePolicy(GameTestH } private static final class Probe extends BasePoweredMachine { - final ItemStorage items = new ItemStorage(1); + final ItemStorage items = new ItemStorage(2).slotId(0, "second").slotId(1, "first"); + final FluidStorage tanks = new FluidStorage(); + ItemStack itemAtRestore = ItemStack.EMPTY; final MachineInventoryLifecycle lifecycle; final RedstoneControl redstone = new RedstoneControl(this, List.of()); MachineInventoryLifecycle.PausePolicy policy = @@ -197,6 +265,9 @@ private static final class Probe extends BasePoweredMachine { 40, 20); setLevel(helper.getLevel()); + tanks.addTank(2000, Role.STORAGE); + tanks.addTank(2000, Role.STORAGE); + tanks.tankId(0, "second").tankId(1, "first"); var group = new ResourceGroup( "energy", @@ -214,7 +285,8 @@ private static final class Probe extends BasePoweredMachine { : (mode == 1 ? new MachineInventoryLifecycle(this, features, automation, energy) : poweredInventory(automation)) - .items("items", items); + .items("items", items) + .fluids("fluids", tanks); features.add( new MachineFeature() { public String key() { @@ -232,6 +304,7 @@ public Integer state() { public void restore(Integer value) { featureValue = value; powerAtRestore = energy.stored(); + itemAtRestore = items.getStackInSlot(0).copy(); } public void reset() { @@ -264,6 +337,13 @@ protected MachineInventoryLifecycle.PausePolicy pausePolicy() { return policy; } + @Override + protected IntUnaryOperator legacySlots(String key, CompoundTag tag) { + return tag.getInt("metadata") == 1 && (key.equals("items") || key.equals("fluids")) + ? index -> 1 - index + : IntUnaryOperator.identity(); + } + @Override protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) { tag.putInt("metadata", metadata); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java index a4f2ab84..de04bac3 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java @@ -234,6 +234,16 @@ public static void frame(RenderFrameEvent.Post event) { var expected = CompanionMod.MACHINES.get(stage).type.get(); if (menu.blockEntity.getType() != expected || ++frames < 25) return; try { + if (Boolean.getBoolean("mmdlib.processorButtonSmokeOnly")) { + waitingTicks = 0; + frames = 0; + if (!ProcessorButtonClientSmoke.run(screen)) return; + finished = true; + MMDLib.LOGGER.info( + "COMPANION_CLIENT_PASS: focused processor button states, tooltips and clicks"); + mc.stop(); + return; + } if (stage == 0 && (!CuriosClientSmoke.run(screen) || !BackpackClientSmoke.run(screen))) { frames = 0; waitingTicks = 0; diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java index 718b6d92..60dc779d 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java @@ -205,7 +205,11 @@ public static void processorActivityTracksWorkAndHostGates(GameTestHelper helper machine.serverTick(); require( !processor.processing() && !processor.paused(), - "An energy stall is idle, not manually paused"); + "An energy stall stops work without manually pausing"); + require( + zone.moddev.mc.lib.gui.model.ProcessorButtonState.of(processor) + == zone.moddev.mc.lib.gui.model.ProcessorButtonState.STALLED, + "The processor control shows the energy stall instead of idle"); machine.energy.setStored(100000); machine.serverTick(); machine.setReservePercent(100); @@ -223,6 +227,9 @@ public static void processorActivityTracksWorkAndHostGates(GameTestHelper helper "Redstone holds work without manually pausing or releasing its reservation"); processor.pause(); require(processor.paused() && !processor.processing(), "Manual pause has its own state"); + require( + !machine.items.hasPlan(0) && processor.progress() > 0, + "Manual pause releases reservations but keeps progress as the tooltip promises"); processor.resume(); machine.redstone.mode(0, zone.moddev.mc.lib.api.redstone.RedstoneControl.Mode.IGNORE); machine.serverTick(); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java new file mode 100644 index 00000000..205168f6 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/MachineRecipeLoadingTests.java @@ -0,0 +1,217 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import com.google.gson.JsonParser; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.*; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.*; +import net.minecraft.world.item.crafting.*; + +import net.neoforged.neoforge.gametest.*; + +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.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 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 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( + 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) { + require( + expected.getMessage().contains("mmdlib_companion:bad_recipe") + && expected.getMessage().contains(MACHINE.toString()), + "Error identifies both recipe and machine"); + } + } + 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( + h, + read( + h, + "mmdlib_companion:batch", + prefix + "\"first\":" + SINGLE + ",\"second\":" + SINGLE + "}}")); + 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( + "\"time\":1", "\"neoforge:conditions\":[{\"type\":\"neoforge:false\"}],\"time\":1"); + require( + load( + 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"); + } + 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) { + require( + error.getMessage().contains("machine definitions differ"), + "Clear compatibility diagnostic"); + } + } + } finally { + data.release(); + } + } + h.succeed(); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java index 5235cda2..97d634c6 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/ProcessorButtonClientSmoke.java @@ -56,7 +56,12 @@ static boolean run(MachineScreen screen) { case 1 -> { if (state != ProcessorButtonState.PROCESSING || menu.redstone.watching()) return false; require(!menu.processorConflict(button), "No monitoring means no red dot"); - tooltip(screen, button, "Working", "Click to stop and clear reservations"); + tooltip( + screen, + button, + "Working", + "Click to pause and release reservations", + "Progress is kept"); require( button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_stop"), "An actively processing machine shows Stop"); @@ -71,16 +76,17 @@ static boolean run(MachineScreen screen) { case 2 -> { if (!menu.redstone.watching() || !menu.redstone.paused() - || state != ProcessorButtonState.IDLE) return false; + || state != ProcessorButtonState.HELD) return false; require( - button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_play"), - "A redstone-held processor is not processing and shows Play"); + button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_stop"), + "A redstone-held processor retains work and shows Stop"); require(menu.processorConflict(button), "Enabled processor conflicts with redstone hold"); tooltip( screen, button, - "Idle", - "Click to pause", + "Held by machine controls", + "Click to pause and release reservations", + "Progress is kept", "Redstone settings conflict:", "Paused by redstone."); capture("processor-redstone-hold.png"); @@ -121,14 +127,19 @@ static boolean run(MachineScreen screen) { server(machine -> machine.energy.setStored(0)); } case 6 -> { - if (state != ProcessorButtonState.IDLE) return false; + if (state != ProcessorButtonState.STALLED) return false; require( !menu.processorConflict(button), "Lack of power is not a manual/redstone disagreement"); - tooltip(screen, button, "Idle", "Click to pause"); + tooltip( + screen, + button, + "Needs power", + "Click to pause and release reservations", + "Progress is kept"); require( - button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_play"), - "An unpaused processor without power shows Play"); - capture("processor-idle-no-power.png"); + button.iconFor(wireState).orElseThrow().getPath().equals("icon_processor_stop"), + "A stalled processor without power retains work and shows Stop"); + capture("processor-stalled-no-power.png"); server( machine -> { machine.energy.setStored(savedEnergy); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java new file mode 100644 index 00000000..d726e12d --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java @@ -0,0 +1,281 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.*; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.Difficulty; +import net.minecraft.world.item.*; +import net.minecraft.world.level.*; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.presets.WorldPresets; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.*; + +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.neoforge.*; + +/** Focused real-client check of XML registration, generated layout, recipes and /reload. */ +@EventBusSubscriber(modid = "mmdlib_companion", value = Dist.CLIENT) +public final class SimpleCrusherClientSmoke { + private static final ResourceLocation ID = + ResourceLocation.parse("mmdlib_companion:simple_crusher"); + private static final ResourceLocation TYPE = + ResourceLocation.parse("mmdlib_companion:simple_crushing"); + 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, reloadRequested; + private static volatile boolean reloaded; + private static boolean recipesOpened; + private static int frames; + + @SubscribeEvent + public static void tick(ClientTickEvent.Post event) { + if (!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") || 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( + "SimpleCrusher smoke", + GameType.CREATIVE, + false, + Difficulty.PEACEFUL, + true, + new GameRules(), + WorldDataConfiguration.DEFAULT); + mc.createWorldOpenFlows() + .createFreshLevel( + "simple-crusher-" + 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; + 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 <= 5; x++) + for (int z = -5; z <= 5; z++) + level.setBlock( + new BlockPos(x, 99, z), + net.minecraft.world.level.block.Blocks.STONE.defaultBlockState(), + 3); + player.teleportTo(.5, 100, 3.5); + level.setBlock(POS, BuiltInRegistries.BLOCK.get(ID).defaultBlockState(), 3); + var machine = (DefinedMachineEntity) level.getBlockEntity(POS); + machine.energy.setStored(90000); + machine.items.insertItem(0, new ItemStack(Items.IRON_ORE, 32), false); + for (int i = 0; i < 31; i++) machine.serverTick(); + } catch (Throwable error) { + fail(error); + } + }); + } + if (!requested && mc.level.getBlockEntity(POS) instanceof DefinedMachineEntity) { + requested = true; + server.execute( + () -> { + try { + var player = server.getPlayerList().getPlayers().getFirst(); + if (!ScreenRuntime.open(player, player.serverLevel().getBlockEntity(POS))) + throw new AssertionError("Menu refused"); + } catch (Throwable error) { + fail(error); + } + }); + } + } catch (Throwable error) { + fail(error); + } + } + + @SubscribeEvent + public static void rendered(RenderFrameEvent.Post event) { + if (!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") || finished) return; + var mc = Minecraft.getInstance(); + if (recipesOpened) { + if (++frames < 10) return; + finished = true; + Screenshot.grab( + mc.gameDirectory, "simple-crusher-recipes.png", mc.getMainRenderTarget(), message -> {}); + MMDLib.LOGGER.info( + "SIMPLE_CRUSHER_CLIENT_PASS: centered screen, arrow recipe click, assets, synchronized" + + " recipes and reload"); + mc.stop(); + return; + } + if (!(mc.screen instanceof MachineScreen screen) + || !screen.getMenu().bound.screen().id().equals(ID)) return; + try { + if (++frames < 30) return; + var menu = screen.getMenu(); + var progress = menu.bound.screen().find("generated_progress").orElseThrow(); + if (menu.value(progress, true) == null) + throw new AssertionError("Progress maximum not synced"); + if (MachineContent.recipeType(TYPE).recipes(mc.level.getRecipeManager()).isEmpty()) + throw new AssertionError("Generated recipes not synced"); + for (var name : java.util.List.of("cobblestone", "gravel")) + if (mc.level + .getRecipeManager() + .byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone/" + name)) + .isEmpty()) throw new AssertionError("Bundled recipe not synchronized: " + name); + var model = mc.getBlockRenderer().getBlockModel(mc.level.getBlockState(POS)); + if (model.getParticleIcon().contents().name().getPath().equals("missingno")) + throw new AssertionError("Generated block model missing"); + if (!reloadRequested) { + reloadRequested = true; + Screenshot.grab( + mc.gameDirectory, "simple-crusher.png", mc.getMainRenderTarget(), message -> {}); + var server = mc.getSingleplayerServer(); + server.execute( + () -> { + server.getPlayerList().getPlayers().getFirst().closeContainer(); + reloadAfterRejectedCandidate(server) + .whenComplete( + (ignored, error) -> { + if (error != null) { + fail(error); + return; + } + server.execute( + () -> { + try { + if (MachineContent.recipeType(TYPE) + .recipes(server.getRecipeManager()) + .isEmpty()) + throw new AssertionError("Recipes missing after reload"); + reloaded = true; + var player = server.getPlayerList().getPlayers().getFirst(); + ScreenRuntime.open( + player, player.serverLevel().getBlockEntity(POS)); + } catch (Throwable failure) { + fail(failure); + } + }); + }); + }); + frames = 0; + return; + } + if (!reloaded) return; + Screenshot.grab( + mc.gameDirectory, "simple-crusher-reloaded.png", mc.getMainRenderTarget(), message -> {}); + var field = + Class.forName("zone.moddev.mc.lib.client.jei.MachineJei").getDeclaredField("runtime"); + field.setAccessible(true); + var runtime = (mezz.jei.api.runtime.IJeiRuntime) field.get(null); + var type = + zone.moddev.mc.lib.client.jei.MachineRecipeCategory.recipeType( + MachineContent.recipeType(TYPE)); + if (runtime == null + || runtime.getRecipeManager().createRecipeLookup(type).get().findAny().isEmpty()) + throw new AssertionError("JEI crusher recipes unavailable"); + if (runtime + .getRecipeManager() + .createRecipeLookup(type) + .get() + .noneMatch( + holder -> + holder + .id() + .equals( + ResourceLocation.parse( + "mmdlib_companion:simple_crushing/stone/cobblestone")))) + throw new AssertionError("Bundled recipe missing from JEI after reload"); + var longestExpectedDetails = + net.minecraft.network.chat.Component.translatable("jei.mmdlib.machine.energy", "2,900") + .append(" · ") + .append( + net.minecraft.network.chat.Component.translatable( + "jei.mmdlib.machine.time", "7.25")); + if (runtime.getRecipeManager().getRecipeCategory(type).getWidth() + < mc.font.width(longestExpectedDetails)) + throw new AssertionError("JEI category clips fractional processing time"); + var bounds = screen.bounds(progress); + if (!screen.mouseClicked( + bounds.x() + bounds.width() / 2., bounds.y() + bounds.height() / 2., 0) + || mc.screen != runtime.getRecipesGui()) + throw new AssertionError("Processor arrow did not open JEI recipes"); + recipesOpened = true; + frames = 0; + } catch (Throwable error) { + fail(error); + } + } + + private static void fail(Throwable error) { + finished = true; + MMDLib.LOGGER.error("SIMPLE_CRUSHER_CLIENT_FAIL", error); + Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); + } + + private static java.util.concurrent.CompletableFuture reloadAfterRejectedCandidate( + net.minecraft.server.MinecraftServer server) { + var selected = java.util.List.copyOf(server.getPackRepository().getSelectedIds()); + var active = server.getRecipeManager(); + var before = java.util.List.copyOf(active.getRecipes()); + var pack = + server + .getWorldPath(net.minecraft.world.level.storage.LevelResource.DATAPACK_DIR) + .resolve("mmdlib-invalid-reload"); + try { + var recipe = pack.resolve("data/mmdlib_companion/recipe/rejected.json"); + java.nio.file.Files.createDirectories(recipe.getParent()); + int format = + net.minecraft.SharedConstants.getCurrentVersion() + .getPackVersion(net.minecraft.server.packs.PackType.SERVER_DATA); + java.nio.file.Files.writeString( + pack.resolve("pack.mcmeta"), + "{\"pack\":{\"pack_format\":" + + format + + ",\"description\":\"Failed reload regression fixture\"}}"); + java.nio.file.Files.writeString( + recipe, + """ + {"type":"mmdlib_companion:simple_crushing", + "inputs":{"misspelled_input":[{"items":{"item":"minecraft:stone"}}]},"time":1} + """); + server.getPackRepository().reload(); + var failing = new java.util.ArrayList<>(selected); + failing.add("file/mmdlib-invalid-reload"); + return server + .reloadResources(failing) + .handleAsync( + (ignored, error) -> { + if (error == null) + throw new AssertionError("Invalid authored group did not reject reload"); + if (server.getRecipeManager() != active + || !java.util.List.copyOf(active.getRecipes()).equals(before)) + throw new AssertionError("Rejected reload changed the active recipes"); + MMDLib.LOGGER.info("SIMPLE_CRUSHER_REJECTED_RELOAD_PASS: active recipes preserved"); + return server.reloadResources(selected); + }, + server) + .thenCompose(future -> future); + } catch (java.io.IOException error) { + return java.util.concurrent.CompletableFuture.failedFuture(error); + } + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherTests.java new file mode 100644 index 00000000..9f1f54ab --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherTests.java @@ -0,0 +1,468 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.core.*; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.*; +import net.minecraft.nbt.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.PackType; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.item.*; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.level.block.Blocks; + +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.gametest.*; + +import zone.moddev.mc.lib.api.automation.ResourceType; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.machine.neoforge.*; +import zone.moddev.mc.lib.machine.recipe.*; +import zone.moddev.mc.lib.machine.recipe.generation.*; + +@GameTestHolder("mmdlib_companion") +@PrefixGameTestTemplate(false) +public final class SimpleCrusherTests { + private static final ResourceLocation ID = + ResourceLocation.parse("mmdlib_companion:simple_crusher"); + private static final ResourceLocation TYPE = + ResourceLocation.parse("mmdlib_companion:simple_crushing"); + private static final ResourceLocation IRON = + ResourceLocation.parse("mmdlib_companion:generated/simple_crushing/ores/iron"); + + private static DefinedMachineEntity place(GameTestHelper h) { + var pos = h.absolutePos(new BlockPos(2, 2, 2)); + h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(ID).defaultBlockState(), 3); + return (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + + @GameTest(template = "empty") + public static void generatedRecipesDriveAClasslessCrusher(GameTestHelper h) { + var machine = place(h); + var type = MachineContent.recipeType(TYPE); + require( + machine.getClass() == DefinedMachineEntity.class, "Generic entity, no crusher subclass"); + var holder = + type.recipes(h.getLevel().getRecipeManager()).stream() + .filter(r -> r.id().equals(ResourceLocation.parse(IRON + "/minecraft/iron_ore"))) + .findFirst() + .orElseThrow(); + require(holder.value().recipe().time() == 130, "XML hardness scaling applied to iron ore"); + machine.energy.setStored(10000); + require( + machine.items.insertItem(0, new ItemStack(Items.IRON_ORE), false).isEmpty(), + "Recipe-linked ore admission"); + for (int i = 0; i < 131; i++) machine.serverTick(); + require(machine.items.getStackInSlot(0).isEmpty(), "Consumed input group"); + require( + machine.items.getStackInSlot(1).is(Items.IRON_INGOT) + && machine.items.getStackInSlot(1).getCount() == 2, + "Default output count and XML group"); + require(machine.energy.stored() == 7400, "XML hardness scaling applied to total energy"); + require( + Screens.get(ID).orElseThrow().screen().find("generated_progress").isPresent(), + "Generated screen installed"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void capabilitiesSaveAndReservationsUseInheritedStorage(GameTestHelper h) { + var machine = place(h); + var pos = machine.getBlockPos(); + var items = h.getLevel().getCapability(Capabilities.ItemHandler.BLOCK, pos, Direction.NORTH); + var power = h.getLevel().getCapability(Capabilities.EnergyStorage.BLOCK, pos, Direction.NORTH); + require( + items != null && items.getSlots() == 3 && power != null, + "Capabilities registered automatically"); + require( + items.insertItem(1, new ItemStack(Items.IRON_ORE), false).getCount() == 1, + "Outputs refuse automation insertion"); + require( + items.insertItem(0, new ItemStack(Items.DIRT), false).getCount() == 1, + "Recipe admission refuses unrelated items"); + power.receiveEnergy(1000, false); + power.receiveEnergy(1000, false); + power.receiveEnergy(600, false); + items.insertItem(0, new ItemStack(Items.IRON_ORE), false); + for (int i = 0; i < 26; i++) machine.serverTick(); + var saved = machine.saveWithoutMetadata(h.getLevel().registryAccess()); + machine.loadWithComponents(saved, h.getLevel().registryAccess()); + require(machine.getProgress() == 25, "Reserved cycle restored after storage"); + for (int i = 0; i < 105; i++) machine.serverTick(); + require( + machine.items.getStackInSlot(1).getCount() == 2 && machine.energy.stored() == 0, + "Resumed without duplicating inputs or power"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void blockedOutputsDoNotConsumeResources(GameTestHelper h) { + var machine = place(h); + machine.energy.setStored(10000); + machine.items.setStackInSlot(1, new ItemStack(Items.GOLD_INGOT, 64)); + machine.items.setStackInSlot(2, new ItemStack(Items.GOLD_INGOT, 64)); + machine.items.insertItem(0, new ItemStack(Items.IRON_ORE), false); + machine.serverTick(); + require( + machine.items.getStackInSlot(0).getCount() == 1 && machine.energy.stored() == 10000, + "Blocked output holds resources"); + machine.items.setStackInSlot(2, ItemStack.EMPTY); + for (int i = 0; i < 131; i++) machine.serverTick(); + require( + machine.items.getStackInSlot(2).is(Items.IRON_INGOT), + "Products choose available member of output group"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void unknownMetalsAndAuthoredOverridesAndFreshGeneration(GameTestHelper h) { + var tags = new TreeMap>>(); + tags.put( + ResourceLocation.parse("c:ores/testium"), + List.of(Items.ANCIENT_DEBRIS.builtInRegistryHolder())); + tags.put( + ResourceLocation.parse("c:ingots/testium"), + List.of( + Items.NETHERITE_INGOT.builtInRegistryHolder(), + Items.IRON_INGOT.builtInRegistryHolder())); + var id = + ResourceLocation.parse( + "mmdlib_companion:generated/simple_crushing/ores/testium/minecraft/ancient_debris"); + var generated = + GeneratedMachineRecipes.generate( + List.of(), h.getLevel().registryAccess(), new ReloadTags(tags)); + var recipe = + (MachineRecipeEntry) + generated.stream().filter(r -> r.id().equals(id)).findFirst().orElseThrow().value(); + require( + recipe.getResultItem(h.getLevel().registryAccess()).is(Items.IRON_INGOT), + "Deterministic output tie-break"); + var again = + GeneratedMachineRecipes.generate( + List.of(), h.getLevel().registryAccess(), new ReloadTags(tags)); + require(again.size() == generated.size(), "Regeneration does not accumulate entries"); + var authored = + new RecipeHolder<>( + id, + new MachineRecipeEntry( + MachineContent.recipeType(TYPE), + MachineRecipeBuilder.recipe(MachineContent.recipeType(TYPE)) + .inputItems( + "input", + net.minecraft.world.item.crafting.Ingredient.of(Items.ANCIENT_DEBRIS), + 1) + .outputItem("output", new ItemStack(Items.DIAMOND)) + .time(1) + .build())); + var combined = + GeneratedMachineRecipes.generate( + List.of(authored), h.getLevel().registryAccess(), new ReloadTags(tags)); + require( + combined.stream().filter(r -> r.id().equals(id)).findFirst().orElseThrow() == authored, + "Authored ID overrides generated"); + require( + GeneratedMachineRecipes.generate( + List.of(), h.getLevel().registryAccess(), new ReloadTags(Map.of())) + .isEmpty(), + "Removed tags remove recipes"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void generatorDefaultsAndOrderedPreferences(GameTestHelper h) { + var modItem = + BuiltInRegistries.ITEM.stream() + .filter( + item -> + BuiltInRegistries.ITEM.getKey(item).getNamespace().equals("mmdlib") + && item.getDefaultInstance().getMaxStackSize() >= 2) + .findFirst() + .orElseThrow(); + var tags = + new ReloadTags( + Map.of( + ResourceLocation.parse("c:ores/testium"), + List.of(Items.IRON_ORE.builtInRegistryHolder()), + ResourceLocation.parse("c:ingots/testium"), + List.of( + modItem.builtInRegistryHolder(), + Items.IRON_INGOT.builtInRegistryHolder()))); + var defaults = direct(h, tags, Map.of()).values().iterator().next(); + require( + defaults.time() == 100 && defaults.inputs().get("energy").getFirst().amount() == 2000, + "Code defaults"); + require( + defaults.inputs().get("input").getFirst().amount() == 1 + && product(defaults).getCount() == 2, + "One ore to two ingots independent of slots"); + require(product(defaults).is(Items.IRON_INGOT), "Minecraft preferred by default"); + var renamed = + direct( + h, + tags, + Map.of("input-group", "ores", "output-group", "ingots", "energy-group", "power")) + .values() + .iterator() + .next(); + require( + renamed.inputs().keySet().equals(Set.of("ores", "power")) + && renamed.outputs().keySet().equals(Set.of("ingots")), + "Optional group name overrides"); + var custom = + direct( + h, + tags, + Map.of( + "preferred-mods", "missing; mmdlib ;minecraft", "time", "17", "energy", "41")) + .values() + .iterator() + .next(); + require(product(custom).is(modItem), "First available preferred namespace wins"); + require( + custom.time() == 17 && custom.inputs().get("energy").getFirst().amount() == 41, + "Optional cost overrides"); + var reversed = + direct(h, tags, Map.of("preferred-mods", "minecraft;mmdlib")).values().iterator().next(); + require(product(reversed).is(Items.IRON_INGOT), "Preference order changes output"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void generatorScalesEachOreUsingStagedBlockTags(GameTestHelper h) { + Map>> items = + Map.of( + ResourceLocation.parse("c:ores/iron"), + List.of( + Items.IRON_ORE.builtInRegistryHolder(), + Items.DEEPSLATE_IRON_ORE.builtInRegistryHolder()), + ResourceLocation.parse("c:ingots/iron"), + List.of(Items.IRON_INGOT.builtInRegistryHolder())); + List> ores = + List.of( + Blocks.IRON_ORE.builtInRegistryHolder(), + Blocks.DEEPSLATE_IRON_ORE.builtInRegistryHolder()); + var tags = new ReloadTags(items, Map.of(BlockTags.NEEDS_IRON_TOOL.location(), ores)); + var props = Map.of("tool-level-ratio", "0.25", "hardness-level-ratio", "0.1"); + var generated = direct(h, tags, props); + var iron = generated.get(ResourceLocation.parse(IRON + "/minecraft/iron_ore")); + var deep = generated.get(ResourceLocation.parse(IRON + "/minecraft/deepslate_iron_ore")); + require( + generated.size() == 2 && iron.time() == 180 && deep.time() == 195, + "Per-block hardness and staged tool requirement"); + require( + iron.inputs().get("energy").getFirst().amount() == 3600 + && deep.inputs().get("energy").getFirst().amount() == 3900, + "Scale total energy with same multiplier"); + var ingredient = + (zone.moddev.mc.lib.machine.ingredient.MachineIngredient.ItemPart) + iron.inputs().get("input").getFirst(); + require( + ingredient.items().test(new ItemStack(Items.IRON_ORE)) + && !ingredient.items().test(new ItemStack(Items.DEEPSLATE_IRON_ORE)), + "Each cost matches only its ore variant"); + var changed = + direct( + h, new ReloadTags(items, Map.of(BlockTags.NEEDS_DIAMOND_TOOL.location(), ores)), props); + require( + changed.get(ResourceLocation.parse(IRON + "/minecraft/iron_ore")).time() == 205, + "New staged tags replace old requirements"); + var rounded = + direct(h, tags, Map.of("time", "1", "energy", "1", "hardness-level-ratio", "0.01")); + require( + rounded.values().stream() + .allMatch( + recipe -> + recipe.time() == 2 && recipe.inputs().get("energy").getFirst().amount() == 2), + "Fractional costs round up"); + var perLevel = direct(h, tags, Map.of("tool-level-ratio", ".10")); + require( + perLevel.values().stream() + .allMatch( + recipe -> + recipe.time() == 120 + && recipe.inputs().get("energy").getFirst().amount() == 2400), + ".10 adds ten percent per level without compounding"); + var stoneTags = new ReloadTags(items, Map.of(BlockTags.NEEDS_STONE_TOOL.location(), ores)); + var boundary = direct(h, stoneTags, Map.of("tool-level-ratio", ".10", "energy", "100")); + require( + boundary.values().stream() + .allMatch( + recipe -> + recipe.time() == 110 + && recipe.inputs().get("energy").getFirst().amount() == 110), + "Double roundoff at 100 * 1.1 does not add an extra unit"); + var fraction = direct(h, stoneTags, Map.of("tool-level-ratio", ".100001", "energy", "100")); + require( + fraction.values().stream() + .allMatch( + recipe -> + recipe.time() == 111 + && recipe.inputs().get("energy").getFirst().amount() == 111), + "Real fractional costs still round up"); + var zero = direct(h, tags, Map.of("tool-level-ratio", "0.0", "hardness-level-ratio", "0.0")); + require( + zero.size() == 1 + && zero.get(IRON).time() == 100 + && zero.get(IRON).inputs().get("energy").getFirst().amount() == 2000, + "Explicit zero ratios preserve default costs and tag recipe IDs"); + var unchanged = + direct( + h, + new ReloadTags(items), + Map.of( + "tool-level-ratio", + ".10", + "time", + Integer.toString(Integer.MAX_VALUE), + "energy", + Long.toString(Long.MAX_VALUE))); + require( + unchanged.values().stream() + .allMatch( + recipe -> + recipe.time() == Integer.MAX_VALUE + && recipe.inputs().get("energy").getFirst().amount() == Long.MAX_VALUE), + "Factor one preserves exact maximum integer costs"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void generatorRejectsBadOptionsAndOverflow(GameTestHelper h) { + for (var option : + List.of( + Map.of("preferred-mods", "minecraft:mmdlib"), + Map.of("preferred-mods", "minecraft;"), + Map.of("tool-level-ratio", "NaN"), + Map.of("hardness-level-ratio", "Infinity"), + Map.of("hardness-level-ratio", "-0.1"), + Map.of("preferred-mod", "minecraft"), + Map.of("input-count", "2"), + Map.of("time", "0"))) { + try { + direct(h, new ReloadTags(Map.of()), option); + throw new AssertionError("Accepted invalid options " + option); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("test-generator.xml:1"), "Source-aware option error"); + } + } + var tags = + new ReloadTags( + Map.of( + ResourceLocation.parse("c:ores/iron"), + List.of(Items.IRON_ORE.builtInRegistryHolder()), + ResourceLocation.parse("c:ingots/iron"), + List.of(Items.IRON_INGOT.builtInRegistryHolder()))); + try { + direct(h, tags, Map.of("energy", Long.toString(Long.MAX_VALUE), "hardness-level-ratio", "1")); + throw new AssertionError("Accepted energy overflow"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("Scaled energy"), "Overflow fails instead of wrapping"); + } + for (var options : + List.of( + Map.of("time", Integer.toString(Integer.MAX_VALUE), "hardness-level-ratio", ".10"), + Map.of("hardness-level-ratio", "1e308"))) { + try { + direct(h, tags, options); + throw new AssertionError("Accepted non-finite or overflowing scaled time"); + } catch (IllegalArgumentException expected) { + require( + expected.getMessage().contains("Scaled time"), + "Reject time overflow and non-finite products"); + } + } + h.succeed(); + } + + private static Map direct( + GameTestHelper h, ReloadTags tags, Map options) { + var result = new TreeMap(); + new OreToIngotGenerator() + .generate( + new RecipeGenerationContext( + MachineContent.recipeType(TYPE), + h.getLevel().registryAccess(), + tags, + List.of(), + new GeneratorProperties(options, "test-generator.xml", 1)), + (id, recipe) -> { + require(result.putIfAbsent(id, recipe) == null, "No duplicate generated IDs"); + }); + return result; + } + + private static ItemStack product(MachineRecipe recipe) { + return ((MachineProduct.ItemProduct) recipe.outputs().get("output").getFirst()).stack(); + } + + @GameTest(template = "empty") + public static void recoveryCapsuleRetainsOverflowAndUnknownResources(GameTestHelper h) { + var machine = place(h); + machine.energy.setStored(machine.energy.capacity() - 100); + var entry = new CompoundTag(); + entry.putString("type", ResourceType.ITEMS.id().toString()); + var data = new CompoundTag(); + data.put("item", new ItemStack(Items.IRON_ORE, 2).save(h.getLevel().registryAccess())); + entry.put("data", data); + var unknown = new CompoundTag(); + unknown.putString("type", "example:unknown"); + var unknownData = new CompoundTag(); + unknownData.putInt("amount", 7); + unknown.put("data", unknownData); + var entries = new ListTag(); + entries.add(entry); + entries.add(unknown); + var payload = new CompoundTag(); + payload.put("resources", entries); + payload.putLong("refund", 250); + var capsule = MachineRecoveryItem.create(payload); + require(MachineRecoveryItem.restore(capsule, machine), "Restored compatible resources"); + require(machine.items.getStackInSlot(0).getCount() == 2, "Restored items once"); + var remainder = + capsule.get(DataComponents.CUSTOM_DATA).copyTag().getCompound("mmdlib_recovery"); + require(remainder.getLong("refund") == 150, "Energy overflow retained"); + require( + remainder.getList("resources", Tag.TAG_COMPOUND).getCompound(0).equals(unknown), + "Unknown resource retained"); + require(!MachineRecoveryItem.restore(capsule, machine), "No progress leaves capsule intact"); + machine.energy.setStored(0); + require(MachineRecoveryItem.restore(capsule, machine), "Remaining energy restored"); + require( + machine.energy.stored() == 150 && !capsule.isEmpty(), + "Unknown payload remains recoverable"); + require(machine.items.getStackInSlot(0).getCount() == 2, "No duplicate items on retry"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void resourcePackContainsCompleteCubeAndDropDefaults(GameTestHelper h) { + var client = MachineResourcePack.build(PackType.CLIENT_RESOURCES); + var server = MachineResourcePack.build(PackType.SERVER_DATA); + for (String path : + List.of( + "models/block/simple_crusher.json", + "models/item/simple_crusher.json", + "blockstates/simple_crusher.json", + "lang/en_us.json")) + require( + client.containsKey(ResourceLocation.fromNamespaceAndPath("mmdlib_companion", path)), + "Generated client resource " + path); + require( + server.containsKey( + ResourceLocation.parse("mmdlib_companion:loot_table/blocks/simple_crusher.json")), + "Generated loot table"); + require( + server.containsKey(ResourceLocation.parse("minecraft:tags/block/mineable/pickaxe.json")), + "Generated mining tag"); + h.succeed(); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/MMDLib.java b/src/main/java/zone/moddev/mc/lib/MMDLib.java index 3f475b04..2600c877 100644 --- a/src/main/java/zone/moddev/mc/lib/MMDLib.java +++ b/src/main/java/zone/moddev/mc/lib/MMDLib.java @@ -104,6 +104,7 @@ public static Map standaloneItems() { public MMDLib(IEventBus modBus, ModContainer container) { container.registerConfig(ModConfig.Type.SERVER, MMDLibConfig.SERVER_SPEC); zone.moddev.mc.lib.gui.runtime.ScreenRuntime.register(modBus); + zone.moddev.mc.lib.machine.neoforge.MachineContent.register(modBus); BLOCKS.register(modBus); ITEMS.register(modBus); machineTypes.register(modBus); @@ -182,7 +183,7 @@ private void loadDefinitions() { .filter(result -> result.material().name().orElseThrow().getNamespace().equals(MODID)) .forEach(runtime::queue); runtime.blockEntityHandles().values().forEach(handle -> handle.register(machineTypes)); - zone.moddev.mc.lib.gui.Screens.install(zone.moddev.mc.lib.gui.load.ScreenLoader.load(roots)); + zone.moddev.mc.lib.machine.neoforge.MachineContent.load(roots); zone.moddev.mc.lib.gui.runtime.ScreenRuntime.validate(); LOGGER.info( "Loaded MMDLib definitions: {} item modules, {} block modules, {} fluid modules, {}" 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 444aa3e3..33899566 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 @@ -131,11 +131,6 @@ public ItemStackHandler slots() { return slots; } - /** The installed addon/filter items, separate from the machine's resource contents. */ - public ItemStackHandler addonSlots() { - return slots; - } - private void viewChanged() { viewRevision++; owner.setChanged(); 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 c4eae242..676c3bd8 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 @@ -146,6 +146,9 @@ public FluidStorage tankId(int tank, String id) { /** 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; 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 aa341da3..bb13cf30 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 @@ -97,6 +97,14 @@ private static List machineRecipeTypes() { */ @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( 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 11816193..901f0ed6 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 @@ -7,6 +7,7 @@ import java.util.List; import java.util.Locale; +import net.minecraft.client.Minecraft; import net.minecraft.core.Holder; import net.minecraft.core.HolderSet; import net.minecraft.network.chat.Component; @@ -39,19 +40,20 @@ public final class MachineRecipeCategory private static final int CELL = 18, COLUMNS = 3, ARROW = 24, GAP = 4, TEXT = 10; private static final int TEXT_COLOR = 0xFF808080; - private final int inputColumns, rows; + private final int inputColumns, rows, contentX; private MachineRecipeCategory( - MachineRecipeType type, int inputColumns, int outputColumns, int rows) { + MachineRecipeType type, int inputColumns, int outputColumns, int rows, int textWidth) { super( recipeType(type), Component.translatableWithFallback( "jei." + type.id().getNamespace() + "." + type.id().getPath(), type.id().toString()), null, - inputColumns * CELL + GAP + ARROW + GAP + outputColumns * CELL, + 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. */ @@ -80,14 +82,16 @@ static List> shown( /** 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; + 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()))); } 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); + return new MachineRecipeCategory(type, inputColumns, outputColumns, rows, textWidth); } @Override @@ -99,13 +103,13 @@ public void setRecipe( for (var part : parts) { switch (part) { case MachineIngredient.ItemPart items -> - slot(builder, RecipeIngredientRole.INPUT, 0, index++) + 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, 0, index++); + 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); @@ -113,7 +117,7 @@ public void setRecipe( case MachineIngredient.EnergyPart energy -> {} } } - int outputX = inputColumns * CELL + GAP + ARROW + GAP; + int outputX = contentX + inputColumns * CELL + GAP + ARROW + GAP; index = 0; for (var products : recipe.outputs().values()) for (var product : products) { @@ -139,7 +143,16 @@ public void setRecipe( public void createRecipeExtras( IRecipeExtrasBuilder builder, RecipeHolder holder, IFocusGroup focuses) { var recipe = holder.value().recipe(); - builder.addRecipeArrowWidget().setPosition(inputColumns * CELL + GAP, (rows * CELL - 17) / 2); + 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() @@ -153,10 +166,7 @@ public void createRecipeExtras( "jei.mmdlib.machine.energy", NumberFormat.getIntegerInstance(Locale.ROOT).format(energy))); lines.add(Component.translatable("jei.mmdlib.machine.time", seconds(recipe.time()))); - builder - .addText(joined(lines), getWidth(), TEXT) - .setPosition(0, rows * CELL + 1) - .setColor(TEXT_COLOR); + return joined(lines); } private IRecipeSlotBuilder slot( 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 267ecd9f..31eaec07 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 @@ -769,7 +769,8 @@ public List tooltipLines(Element e, int mx, int my) { state -> switch (state) { case PAUSED -> "screen.mmdlib.processor.action.unpause"; - case PROCESSING -> "screen.mmdlib.processor.action.stop"; + case PROCESSING, STALLED, BLOCKED, HELD -> + "screen.mmdlib.processor.action.stop"; case IDLE -> "screen.mmdlib.processor.action.pause"; }) .map(key -> Component.translatable(key).getString()) diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java index f9bd8ccf..fde8b3cd 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java @@ -35,10 +35,30 @@ private ScreenLoader() {} * @throws IllegalArgumentException if two files have the same ID */ public static Map load(List sources) { + return load( + sources, + Map.of(), + (id, node) -> { + throw new ScreenException(node.source(), node.line(), "No generator context for " + id); + }); + } + + /** Loads embedded machine screens through the same composition and validation pipeline. */ + public static Map load( + List sources, + Map embedded, + java.util.function.BiFunction> + generator) { WidgetTypes.GLOBAL.freeze(); var discovered = discover(sources); var screens = new TreeMap(); - read(discovered.nodes(), WidgetTypes.GLOBAL) + embedded.forEach( + (id, node) -> { + if (discovered.nodes().putIfAbsent(id, node) != null) + throw new IllegalArgumentException("Duplicate screen " + id + ": " + node.source()); + discovered.loaders().put(id, ScreenLoader.class.getClassLoader()); + }); + read(discovered.nodes(), WidgetTypes.GLOBAL, generator) .forEach((id, screen) -> screens.put(id, bind(screen, discovered.loaders().get(id)))); return screens; } @@ -77,8 +97,22 @@ private static Definitions discover(List sources) { private static Map read( Map definitions, WidgetTypes widgets) { + return read( + definitions, + widgets, + (id, node) -> { + throw new ScreenException(node.source(), node.line(), "No generator context for " + id); + }); + } + + /** Compiles definition trees, useful for generators and definition validation tools. */ + public static Map read( + Map definitions, + WidgetTypes widgets, + java.util.function.BiFunction> + generator) { var screens = new TreeMap(); - var templates = new ScreenTemplates(definitions); + var templates = new ScreenTemplates(definitions, generator); // Discover all definitions first: bases may be in later files or another mod. for (var id : definitions.keySet()) templates.resolve(id); for (var id : definitions.keySet()) { diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java index a9ff4540..775d7392 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java @@ -74,7 +74,7 @@ public static Screen read( } static Screen build(ResourceLocation id, Node root, WidgetTypes widgets) { - validateExpanded(root); + root = validateExpanded(root); String source = root.source(); var screen = new Builder(id, source, widgets).screen(id, root); check(screen); @@ -84,14 +84,24 @@ static Screen build(ResourceLocation id, Node root, WidgetTypes widgets) { // Parsing /** One parsed element, with its attributes as written (schema defaults filled in). */ - record Node( + public record Node( String name, Map attributes, List children, String source, int line) { - Node withChildren(List children) { + public Node withChildren(List children) { return new Node(name, attributes, children, source, line); } } static Node parse(String source, InputStream input) throws IOException { + return parseTree(source, input, true); + } + + /** Secure, source-aware tree reader for machine definitions containing embedded screens. */ + public static Node parseMachine(String source, InputStream input) throws IOException { + return parseTree(source, input, false); + } + + private static Node parseTree(String source, InputStream input, boolean screen) + throws IOException { try { var factory = SAXParserFactory.newInstance(); factory.setNamespaceAware(true); @@ -101,13 +111,13 @@ static Node parse(String source, InputStream input) throws IOException { factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setSchema(SCHEMA); - var handler = new TreeHandler(source); + if (screen) factory.setSchema(SCHEMA); + var handler = new TreeHandler(source, screen); var parser = factory.newSAXParser(); parser.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); parser.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); parser.parse(new InputSource(input), handler); - return handler.root; + return freeze(handler.root); } catch (SAXParseException ex) { throw new ScreenException(source, ex.getLineNumber(), ex.getMessage(), ex); } catch (SAXException | ParserConfigurationException ex) { @@ -115,11 +125,23 @@ static Node parse(String source, InputStream input) throws IOException { } } + private static Node freeze(Node node) { + return new Node( + node.name(), + Map.copyOf(node.attributes()), + node.children().stream().map(ScreenReader::freeze).toList(), + node.source(), + node.line()); + } + /** Check placement again after fills are spliced into their destination containers. */ - private static void validateExpanded(Node root) { + private static Node validateExpanded(Node root) { var validator = SCHEMA.newValidatorHandler(); var locator = new org.xml.sax.helpers.LocatorImpl(); validator.setDocumentLocator(locator); + var tree = new TreeHandler(root.source(), true); + tree.setDocumentLocator(locator); + validator.setContentHandler(tree); validator.setErrorHandler( new DefaultHandler() { @Override @@ -136,6 +158,7 @@ public void fatalError(SAXParseException ex) throws SAXException { validator.startDocument(); validateNode(root, validator, locator); validator.endDocument(); + return freeze(tree.root); } catch (SAXException ex) { throw new ScreenException( locator.getSystemId(), locator.getLineNumber(), ex.getMessage(), ex); @@ -178,9 +201,11 @@ private static final class TreeHandler extends DefaultHandler { private final List stack = new ArrayList<>(); private Locator locator; private Node root; + private final boolean screen; - TreeHandler(String source) { + TreeHandler(String source, boolean screen) { this.source = source; + this.screen = screen; } @Override @@ -191,7 +216,7 @@ public void setDocumentLocator(Locator locator) { @Override public void startElement(String uri, String local, String qName, Attributes attributes) throws SAXException { - if (!NAMESPACE.equals(uri)) + if (!NAMESPACE.equals(uri) && (screen || !"https://moddev.zone/mmdlib/machine".equals(uri))) throw new SAXParseException( "Element <" + qName + "> is not in the screen namespace " + NAMESPACE, locator); var values = new LinkedHashMap(); @@ -199,7 +224,13 @@ public void startElement(String uri, String local, String qName, Attributes attr if (attributes.getURI(i).isEmpty()) values.put(attributes.getLocalName(i), attributes.getValue(i)); var children = new ArrayList(); - var node = new Node(local, values, children, source, locator.getLineNumber()); + var node = + new Node( + local, + values, + children, + locator.getSystemId() == null ? source : locator.getSystemId(), + locator.getLineNumber()); if (stack.isEmpty()) root = node; else open.getLast().add(node); stack.add(node); @@ -402,11 +433,14 @@ private Node processorDefaults(Node node) { var attributes = new LinkedHashMap<>(node.attributes()); attributes.put( "icons", - "mmdlib:icon_processor_pause mmdlib:icon_processor_stop mmdlib:icon_processor_play"); + "mmdlib:icon_processor_pause mmdlib:icon_processor_stop mmdlib:icon_processor_play" + + " mmdlib:icon_processor_stop mmdlib:icon_processor_stop" + + " mmdlib:icon_processor_stop"); attributes.put( "value-labels", "screen.mmdlib.processor.paused screen.mmdlib.processor.processing" - + " screen.mmdlib.processor.idle"); + + " screen.mmdlib.processor.idle screen.mmdlib.machine.stalled" + + " screen.mmdlib.machine.blocked screen.mmdlib.machine.held"); if (!attributes.containsKey("tooltip")) attributes.put("tooltip", "screen.mmdlib.processor.toggle"); return new Node(node.name(), attributes, node.children(), node.source(), node.line()); diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java index 3c074bf2..6ea7af99 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java @@ -15,9 +15,21 @@ final class ScreenTemplates { private final Map definitions; private final Map resolved = new LinkedHashMap<>(); private final LinkedHashSet visiting = new LinkedHashSet<>(); + private final java.util.function.BiFunction> generator; ScreenTemplates(Map definitions) { + this( + definitions, + (id, node) -> { + throw error(node, "No generator context for " + id); + }); + } + + ScreenTemplates( + Map definitions, + java.util.function.BiFunction> generator) { this.definitions = Map.copyOf(definitions); + this.generator = generator; } static boolean flag(Node node, String attribute) { @@ -75,7 +87,7 @@ Node resolve(ResourceLocation id) { Node expand(ResourceLocation id) { var root = resolve(id); if (flag(root, "abstract")) throw error(root, "An abstract screen cannot be opened or bound"); - return root.withChildren(expand(root.children())); + return root.withChildren(expand(id, root.children())); } private static Map placeholders(Node root) { @@ -89,7 +101,10 @@ private static void collect(Node node, Map found) { var name = node.attributes().get("name"); if (found.putIfAbsent(name, node) != null) throw error(node, "Duplicate placeholder: " + name); - if (flag(node, "required") && !node.children().isEmpty()) + if (node.attributes().containsKey("generator") && !node.children().isEmpty()) + throw error(node, "A generator cannot also supply literal children"); + if (flag(node, "required") + && (!node.children().isEmpty() || node.attributes().containsKey("generator"))) throw error(node, "A required placeholder cannot also have default content: " + name); } for (var child : node.children()) collect(child, found); @@ -103,27 +118,38 @@ private static List apply( node.name().equals("placeholder") ? fills.get(node.attributes().get("name")) : null; if (fill != null) { used.add(node.attributes().get("name")); - var attributes = new LinkedHashMap<>(node.attributes()); - attributes.remove("required"); + if (fill.attributes().containsKey("generator") && !fill.children().isEmpty()) + throw error(fill, "A generator cannot also supply literal children"); + var attributes = new LinkedHashMap<>(fill.attributes()); // Keep the extension point so a further descendant can override it again. - result.add(new Node(node.name(), attributes, fill.children(), node.source(), node.line())); + result.add(new Node(node.name(), attributes, fill.children(), fill.source(), fill.line())); } else result.add(node.withChildren(apply(node.children(), fills, used))); } return result; } - private static List expand(List nodes) { + private List expand(ResourceLocation id, List nodes) { var result = new ArrayList(); for (var node : nodes) { if (node.name().equals("placeholder")) { if (flag(node, "required")) throw error(node, "Missing required fill: " + node.attributes().get("name")); - result.addAll(expand(node.children())); - } else result.add(node.withChildren(expand(node.children()))); + if (node.attributes().containsKey("generator")) { + var generated = generator.apply(id, node); + for (var child : generated) rejectTemplates(child); + result.addAll(generated); + } else result.addAll(expand(id, node.children())); + } else result.add(node.withChildren(expand(id, node.children()))); } return result; } + private static void rejectTemplates(Node node) { + if (List.of("screen", "placeholder", "fill").contains(node.name())) + throw error(node, "Generators must return ordinary screen elements"); + node.children().forEach(ScreenTemplates::rejectTemplates); + } + private static ScreenException error(Node node, String message) { return new ScreenException(node.source(), node.line(), message); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java b/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java index 49b9083b..765d8bf5 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java @@ -7,7 +7,23 @@ public enum ProcessorButtonState { PAUSED, PROCESSING, - IDLE; + IDLE, + STALLED, + BLOCKED, + HELD; + + public static ProcessorButtonState of( + zone.moddev.mc.lib.machine.feature.RecipeProcessor processor) { + if (processor.paused() || processor.processing()) + return of(processor.paused(), processor.processing()); + return switch (processor.status()) { + case RUNNING -> PROCESSING; + case STALLED -> STALLED; + case BLOCKED -> BLOCKED; + case HELD -> HELD; + default -> IDLE; + }; + } public static ProcessorButtonState of(boolean paused, boolean processing) { return paused ? PAUSED : processing ? PROCESSING : IDLE; 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 9db6c4ce..91dea4d2 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 @@ -52,7 +52,7 @@ public AutomationMenu( revisions = new long[generations.length]; items = client - ? new ItemStackHandler(inventory.addonSlots().getSlots()) { + ? new ItemStackHandler(inventory.slots().getSlots()) { @Override public int getSlotLimit(int slot) { return 1; @@ -70,7 +70,7 @@ public boolean isItemValid(int slot, ItemStack stack) { && addon.acceptsFilter(parent, stack); } } - : inventory.addonSlots(); + : inventory.slots(); } public int slotCount() { 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 6e184fde..c753cd75 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 @@ -211,9 +211,9 @@ private static BlockEntity readEntity(Inventory inventory, RegistryFriendlyByteB String expected = data.readUtf(64); var screen = Screens.get(BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(entity.getType())).orElseThrow(); - if (!ScreenRuntime.fingerprint(screen.screen()).equals(expected)) + if (!ScreenRuntime.fingerprint(screen.screen(), entity).equals(expected)) throw new IllegalStateException( - "Client/server screen definitions differ: " + screen.screen().id()); + "Client/server screen or machine definitions differ: " + screen.screen().id()); return entity; } @@ -308,7 +308,10 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c .map(e -> (Element.Slot) e) .findFirst() .orElse(null); - if (element != null && element.kind() != Element.SlotKind.GHOST && index >= items.getSlots()) + if (host == null + && element != null + && element.kind() != Element.SlotKind.GHOST + && index >= items.getSlots()) throw new IllegalArgumentException("Screen slot outside storage: " + index); int[] xy = element == null 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 0450afab..0a71b7ee 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 @@ -20,6 +20,11 @@ public interface ScreenHost { */ IItemHandlerModifiable screenItems(); + /** Extra startup-definition identity checked alongside the screen before constructing a menu. */ + default String screenDefinitionFingerprint() { + return ""; + } + /** Registered machine features used by generic processor controls. */ default zone.moddev.mc.lib.machine.feature.FeatureHost screenFeatures() { return null; diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenInventoryValidation.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenInventoryValidation.java index 72dbc104..1fd50138 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenInventoryValidation.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenInventoryValidation.java @@ -40,17 +40,6 @@ public static void validate(Screen screen, ScreenHost host) { + " outside storage of size " + count); } - var automation = host.screenAutomation(); - if (automation == null) return; - for (var group : automation.groups()) - for (String highlight : group.highlights()) - if (screen.find(highlight).isEmpty()) - throw new IllegalArgumentException( - screen.id() - + ": resource group '" - + group.id() - + "' references missing screen element '" - + highlight - + "'"); + // Highlights are optional decoration: a shared group may name elements absent from this screen. } } 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 20520903..3438d5cd 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 @@ -90,6 +90,22 @@ public static String fingerprint(zone.moddev.mc.lib.gui.model.Screen screen) { return screen.fingerprint(); } + /** Includes host storage/group configuration even when it produces an identical screen. */ + public static String fingerprint(zone.moddev.mc.lib.gui.model.Screen screen, BlockEntity entity) { + var definition = entity instanceof ScreenHost host ? host.screenDefinitionFingerprint() : ""; + if (definition.isEmpty()) return fingerprint(screen); + try { + return java.util.HexFormat.of() + .formatHex( + java.security.MessageDigest.getInstance("SHA-256") + .digest( + (fingerprint(screen) + "|" + definition) + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new AssertionError(impossible); + } + } + public static boolean open(Player player, BlockEntity entity) { if (entity == null) return false; var bound = Screens.get(BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(entity.getType())); @@ -106,7 +122,7 @@ public static boolean open(Player player, BlockEntity entity) { (id, inventory, p) -> new MachineMenu(id, inventory, entity, session), title), buf -> { buf.writeBlockPos(entity.getBlockPos()); - buf.writeUtf(fingerprint(bound.get().screen())); + buf.writeUtf(fingerprint(bound.get().screen(), entity)); buf.writeLong(session); }); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java index 22b39b4b..c2a4a0a6 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java @@ -38,10 +38,7 @@ static Map read(BlockEntity entity, ScreenBinder.Bound bound) { var processor = host.screenFeatures().processor(button.processorKey()).orElse(null); if (processor != null) result.put( - i * 2, - zone.moddev.mc.lib.gui.model.ProcessorButtonState.of( - processor.paused(), processor.processing()) - .wireValue()); + i * 2, zone.moddev.mc.lib.gui.model.ProcessorButtonState.of(processor).wireValue()); } for (int max = 0; max < 2; max++) { var getter = (max == 0 ? bound.values() : bound.maxima()).get(e); 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 new file mode 100644 index 00000000..cda388fe --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinition.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.lib.machine.definition; + +import java.util.List; +import java.util.Map; + +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; + +/** Immutable, resolved startup configuration for a powered item-processing machine. */ +public record MachineDefinition( + ResourceLocation id, + String checksum, + String name, + float hardness, + ResourceLocation mineable, + boolean requiresTool, + Map textures, + Power power, + String itemStorageId, + List slots, + List groups, + RecipeType recipeType, + String processor, + String processorName, + boolean redstone, + Node screen) { + public MachineDefinition { + textures = Map.copyOf(textures); + slots = List.copyOf(slots); + groups = List.copyOf(groups); + } + + public record Power(String id, long capacity, long input, long output, int color) {} + + public record Slot(String id, Role role, int limit) {} + + public record Group(String id, List members, IoMode mode, int color) { + public Group { + members = List.copyOf(members); + } + } + + public record RecipeType( + ResourceLocation id, boolean declare, String name, Generator generator) {} + + public record Generator( + String className, + Map properties, + String source, + int line, + ClassLoader loader) { + public Generator { + properties = Map.copyOf(properties); + } + } +} 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 new file mode 100644 index 00000000..af1da325 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineDefinitions.java @@ -0,0 +1,419 @@ +package zone.moddev.mc.lib.machine.definition; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.*; + +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.common.loading.DefinitionLoader.SourceRoot; +import zone.moddev.mc.lib.gui.load.ScreenException; +import zone.moddev.mc.lib.gui.load.ScreenReader; +import zone.moddev.mc.lib.gui.load.ScreenReader.Node; +import zone.moddev.mc.lib.machine.definition.MachineDefinition.*; + +/** + * Discovers machine XML, composes inheritance, then validates concrete plans before registration. + */ +public final class MachineDefinitions { + private MachineDefinitions() {} + + public static Map load(List sources) { + var nodes = new TreeMap(); + var loaders = new HashMap(); + for (var source : sources) { + var folder = source.root().resolve("machine"); + if (!Files.isDirectory(folder)) continue; + try (var paths = Files.walk(folder)) { + for (var path : + paths + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".xml")) + .sorted() + .toList()) { + String relative = folder.relativize(path).toString().replace('\\', '/'); + var id = + ResourceLocation.fromNamespaceAndPath( + source.namespace(), relative.substring(0, relative.length() - 4)); + try (var stream = Files.newInputStream(path)) { + var node = ScreenReader.parseMachine(path.toString(), stream); + validateTree(node); + if (!node.name().equals("machine")) throw error(node, "Expected "); + if (nodes.putIfAbsent(id, node) != null) throw error(node, "Duplicate machine " + id); + loaders.put(node.source(), source.classLoader()); + } + } + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + var resolved = new HashMap(); + var plans = new TreeMap(); + for (var id : nodes.keySet()) { + var node = resolve(id, nodes, resolved, new LinkedHashSet<>()); + if (!bool(nodes.get(id), "abstract", false)) plans.put(id, plan(id, node, loaders)); + } + return Collections.unmodifiableMap(plans); + } + + private static Node resolve( + ResourceLocation id, + Map nodes, + Map resolved, + Set visiting) { + if (resolved.containsKey(id)) return resolved.get(id); + var node = nodes.get(id); + if (node == null) throw new IllegalArgumentException("Unknown base machine " + id); + if (!visiting.add(id)) throw error(node, "Machine inheritance cycle " + visiting); + var parent = node.attributes().get("extends"); + var result = + parent == null + ? node + : merge(resolve(ResourceLocation.parse(parent), nodes, resolved, visiting), node); + visiting.remove(id); + resolved.put(id, result); + return result; + } + + private static String key(Node node) { + return node.name() + + ":" + + node.attributes().getOrDefault("id", node.attributes().getOrDefault("name", "")); + } + + 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()); + attrs.putAll(child.attributes()); + var children = new LinkedHashMap(); + base.children().forEach(n -> children.put(key(n), n)); + for (var n : child.children()) children.merge(key(n), n, MachineDefinitions::merge); + return new Node( + child.name(), + Map.copyOf(attrs), + List.copyOf(children.values()), + child.source(), + child.line()); + } + + 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("storage", Set.of()), + Map.entry("items", Set.of("id")), + 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("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("redstone", Set.of("enabled", "pause")), + Map.entry("recovery", Set.of("policy"))); + private static final Map> CHILDREN = + Map.of( + "machine", + Set.of( + "block", + "appearance", + "storage", + "groups", + "recipe-type", + "processor", + "redstone", + "recovery", + "screen"), + "storage", + Set.of("energy", "items"), + "items", + Set.of("slot", "slots"), + "groups", + Set.of("group"), + "recipe-type", + Set.of("generator")); + + private static void validateTree(Node node) { + if (node.name().equals("screen")) return; // Expanded screen is checked against screen.xsd. + if (!node.name().equals("generator")) { + var allowed = ATTRIBUTES.get(node.name()); + if (allowed == null) throw error(node, "Unknown machine element " + node.name()); + for (var attr : node.attributes().keySet()) + if (!allowed.contains(attr)) throw error(node, "Unknown attribute " + attr); + } + var seen = new HashSet(); + for (var child : node.children()) { + if (!CHILDREN.getOrDefault(node.name(), Set.of()).contains(child.name())) + throw error(child, "Unexpected child of " + node.name()); + if (!seen.add(key(child))) throw error(child, "Duplicate definition " + key(child)); + validateTree(child); + } + } + + private static MachineDefinition plan( + ResourceLocation id, Node root, Map loaders) { + if (!"1".equals(root.attributes().get("version"))) + throw error(root, "Expected machine version=1"); + var block = one(root, "block"); + requireChoice(block, "facing", "horizontal"); + requireChoice(block, "drop", "self"); + var appearance = one(root, "appearance"); + requireChoice(appearance, "preset", "cube-front"); + var textures = new HashMap(); + for (var face : List.of("front", "side", "top")) + textures.put(face, ResourceLocation.parse(required(appearance, face)).toString()); + textures.put( + "bottom", + ResourceLocation.parse(appearance.attributes().getOrDefault("bottom", textures.get("side"))) + .toString()); + var storage = one(root, "storage"); + var power = one(storage, "energy"); + var items = one(storage, "items"); + if (Set.of( + "energy", + "features", + "automation", + "redstone_control", + "redstone_mode", + "id", + "components", + "mmdlib_features", + "mmdlib_machine") + .contains(required(items, "id"))) + throw error(items, "Reserved storage ID " + required(items, "id")); + String powerId = required(power, "id"); + // BasePoweredMachine owns this stable save key. + if (!powerId.equals("energy")) + throw error(power, "Powered machines require energy id='energy'"); + int energyColor = 0xd85b5b; + var slots = new ArrayList(); + var groups = new LinkedHashMap(); + var ids = new HashMap(); + for (var node : items.children()) { + String slotId = required(node, "id"); + Role role; + try { + role = Role.valueOf(required(node, "role").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw error(node, "Invalid slot role"); + } + int count = node.name().equals("slots") ? (int) number(node, "count", 1, 54) : 1; + int limit = node.attributes().containsKey("limit") ? (int) number(node, "limit", 1, 64) : 64; + var members = new ArrayList(); + for (int i = 0; i < count; i++) { + String memberId = node.name().equals("slots") ? slotId + "/" + i : slotId; + if (ids.putIfAbsent(memberId, slots.size()) != null) + throw error(node, "Duplicate slot " + memberId); + members.add(slots.size()); + slots.add(new Slot(memberId, role, limit)); + } + if (node.name().equals("slots")) { + IoMode mode = + switch (role) { + case INPUT -> IoMode.INPUT; + case OUTPUT -> IoMode.OUTPUT; + case STORAGE -> IoMode.BOTH; + case MANUAL -> IoMode.DISABLED; + }; + if (groups.putIfAbsent( + slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f45)) + != null) throw error(node, "Duplicate group " + slotId); + } + } + if (slots.isEmpty() || slots.size() > 54) throw error(items, "Expected 1..54 item slots"); + var groupsNode = optional(root, "groups"); + if (groupsNode != null) + for (var node : groupsNode.children()) { + String groupId = required(node, "id"); + if (required(node, "storage").equals(powerId)) { + if (!groupId.equals(powerId) || !"input".equals(required(node, "mode"))) + throw error(node, "Energy group must be energy/input"); + if (node.attributes().containsKey("members")) + throw error(node, "Energy group has a single implicit member"); + energyColor = color(node, energyColor); + continue; + } + if (!required(node, "storage").equals(required(items, "id"))) + throw error(node, "Unknown storage"); + var prior = groups.get(groupId); + var members = new ArrayList(); + if (node.attributes().containsKey("members")) + for (String member : required(node, "members").split("\\s+")) { + if (!ids.containsKey(member)) throw error(node, "Unknown slot " + member); + members.add(ids.get(member)); + } + else if (prior != null) members.addAll(prior.members()); + if (members.isEmpty()) throw error(node, "Empty group " + groupId); + if (prior != null && !prior.members().equals(members)) + throw error(node, "Conflicting automatic group " + groupId); + IoMode mode; + try { + mode = IoMode.valueOf(required(node, "mode").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw error(node, "Invalid group mode"); + } + groups.put( + groupId, + new Group( + groupId, members, mode, color(node, prior == null ? 0x568bd8 : prior.color()))); + } + var covered = new HashSet(); + for (var group : groups.values()) + for (int index : group.members()) { + if (!covered.add(index)) throw error(items, "Overlapping item groups"); + var role = slots.get(index).role(); + if ((group.mode().canInsert() && !role.canInsert()) + || (group.mode().canExtract() && !role.canExtract())) + throw error(items, "Group mode conflicts with slot role: " + group.id()); + } + if (covered.size() != slots.size()) throw error(items, "Every slot must belong to a group"); + if (groups.containsKey(powerId)) throw error(items, "Item group conflicts with energy"); + var recipe = one(root, "recipe-type"); + var generatorNode = optional(recipe, "generator"); + Generator generator = null; + if (generatorNode != null) { + var props = new HashMap<>(generatorNode.attributes()); + props.remove("class"); + generator = + new Generator( + required(generatorNode, "class"), + props, + generatorNode.source(), + generatorNode.line(), + loaders.get(generatorNode.source())); + } + var recipeId = ResourceLocation.parse(required(recipe, "id")); + var recipeType = + new RecipeType( + recipeId, + bool(recipe, "declare", false), + recipe.attributes().getOrDefault("name", recipeId.getPath()), + generator); + if (!recipeType.declare() && generator != null) + throw error(recipe, "Only a declaring recipe type may supply a generator"); + var processor = one(root, "processor"); + if (!recipeId.equals(ResourceLocation.parse(required(processor, "recipe-type")))) + throw error(processor, "Unknown recipe type"); + // All item input groups are linked automatically; an explicit list must agree. + var inputs = + groups.values().stream().filter(g -> g.mode() == IoMode.INPUT).map(Group::id).toList(); + if (processor.attributes().containsKey("filter-inputs") + && !new HashSet<>(Arrays.asList(required(processor, "filter-inputs").split("\\s+"))) + .equals(new HashSet<>(inputs))) + throw error(processor, "filter-inputs must name the input groups"); + var redstone = optional(root, "redstone"); + if (redstone != null) requireChoice(redstone, "pause", "processing-only"); + var recovery = one(root, "recovery"); + requireChoice(recovery, "policy", "capsule"); + var screen = one(root, "screen"); + var attrs = new HashMap<>(screen.attributes()); + attrs.put("title", "block." + id.getNamespace() + "." + id.getPath().replace('/', '.')); + attrs.put("title-fallback", required(root, "name")); + attrs.put("block-entity", "zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity"); + float hardness = Float.parseFloat(required(block, "hardness")); + if (!Float.isFinite(hardness) || hardness < 0) throw error(block, "Invalid hardness"); + return new MachineDefinition( + id, + checksum(root), + required(root, "name"), + hardness, + ResourceLocation.parse(required(block, "mineable")), + bool(block, "requires-tool", false), + textures, + new Power( + powerId, + number(power, "capacity", 1, Long.MAX_VALUE), + number(power, "input-rate", 0, Long.MAX_VALUE), + number(power, "output-rate", 0, Long.MAX_VALUE), + energyColor), + required(items, "id"), + slots, + List.copyOf(groups.values()), + recipeType, + 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())); + } + + public static String required(Node node, String key) { + var value = node.attributes().get(key); + if (value == null || value.isBlank()) throw error(node, "Missing " + key); + return value; + } + + /** CRC of the resolved XML tree: includes inherited settings, excludes paths/formatting. */ + private static String checksum(Node root) { + var text = new StringBuilder(); + canonical(root, text); + var crc = new java.util.zip.CRC32(); + crc.update(text.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return String.format(java.util.Locale.ROOT, "%08x", crc.getValue()); + } + + private static void canonical(Node node, StringBuilder text) { + token(text, node.name()); + text.append(node.attributes().size()).append(':'); + new TreeMap<>(node.attributes()) + .forEach( + (key, value) -> { + token(text, key); + token(text, value); + }); + text.append(node.children().size()).append(':'); + node.children().forEach(child -> canonical(child, text)); + } + + private static void token(StringBuilder text, String value) { + text.append(value.length()).append(':').append(value); + } + + private static Node optional(Node node, String name) { + var children = node.children().stream().filter(n -> n.name().equals(name)).toList(); + if (children.size() > 1) throw error(node, "Only one " + name + " is supported"); + return children.isEmpty() ? null : children.getFirst(); + } + + private static Node one(Node node, String name) { + var result = optional(node, name); + if (result == null) throw error(node, "Missing " + 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 boolean bool(Node node, String key, boolean fallback) { + var value = node.attributes().get(key); + if (value == null) return fallback; + if (!value.equals("true") && !value.equals("false")) + throw error(node, "Invalid boolean " + key); + return Boolean.parseBoolean(value); + } + + private static long number(Node node, String key, long min, long max) { + try { + long value = Long.parseLong(required(node, key)); + if (value >= min && value <= max) return value; + } catch (NumberFormatException ex) { + /* source-aware error below */ + } + throw error(node, key + " must be in " + min + ".." + max); + } + + 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); + } + + public static ScreenException error(Node node, String message) { + return new ScreenException(node.source(), node.line(), 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 new file mode 100644 index 00000000..24513ab9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/definition/MachineScreenGenerators.java @@ -0,0 +1,165 @@ +package zone.moddev.mc.lib.machine.definition; + +import java.util.*; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.gui.load.ScreenReader.Node; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.model.Screen; + +/** Registered fragment builders; templates retain ownership of the enclosing screen. */ +public final class MachineScreenGenerators { + @FunctionalInterface + public interface Generator { + List generate(MachineDefinition machine, Node placeholder); + } + + private static final Map GENERATORS = new LinkedHashMap<>(); + + static { + register(ResourceLocation.parse("mmdlib:processing"), MachineScreenGenerators::processing); + register(ResourceLocation.parse("mmdlib:resource-groups"), MachineScreenGenerators::resources); + register( + ResourceLocation.parse("mmdlib:processors"), + (machine, node) -> { + options(node, Set.of()); + return List.of(progress(machine, node)); + }); + } + + private MachineScreenGenerators() {} + + public static synchronized void register(ResourceLocation id, Generator generator) { + if (GENERATORS.putIfAbsent(id, Objects.requireNonNull(generator)) != null) + throw new IllegalArgumentException("Duplicate fill generator " + id); + } + + public static List generate(MachineDefinition machine, Node node) { + if (machine == null) + throw MachineDefinitions.error(node, "Machine fill generator requires a machine definition"); + var id = ResourceLocation.parse(node.attributes().get("generator")); + var generator = GENERATORS.get(id); + if (generator == null) throw MachineDefinitions.error(node, "Unknown fill generator " + id); + return List.copyOf(generator.generate(machine, node)); + } + + /** Checks references against the resolved machine before any block can be placed. */ + public static void validate(MachineDefinition machine, Screen screen) { + for (var element : screen.elements()) { + if (element instanceof Element.Slot slot + && slot.kind() != Element.SlotKind.GHOST + && (slot.index() < 0 || slot.index() >= machine.slots().size())) + throw new zone.moddev.mc.lib.gui.load.ScreenException( + screen.source(slot.common()), + slot.common().line(), + "Slot index outside machine storage: " + slot.index()); + if (element instanceof Element.Button button + && button.processorControl() + && button.processorKey().filter(key -> !key.equals(machine.processor())).isPresent()) + throw new zone.moddev.mc.lib.gui.load.ScreenException( + screen.source(button.common()), + button.common().line(), + "Unknown processor-key: " + button.processorKey().orElseThrow()); + } + } + + private static void options(Node node, Set allowed) { + for (var key : node.attributes().keySet()) + if (!Set.of("name", "generator").contains(key) && !allowed.contains(key)) + throw MachineDefinitions.error(node, "Unknown generator option " + key); + } + + private static Node progress(MachineDefinition machine, Node node) { + return n( + node, + "progress", + Map.of( + "id", + "generated_progress", + "value", + "#getProgress", + "max", + "#getTime", + "jei-recipes", + machine.recipeType().id().toString()), + List.of()); + } + + private static List processing(MachineDefinition machine, Node node) { + options(node, Set.of()); + var main = new ArrayList(); + main.add(spacer(node)); + main.addAll(groups(machine, node, IoMode.INPUT, 3)); + main.add(progress(machine, node)); + main.addAll(groups(machine, node, IoMode.OUTPUT, 3)); + main.add(spacer(node)); + var rows = new ArrayList(); + rows.add(spacer(node)); + rows.add(n(node, "hbox", Map.of("spacing", "6", "align", "center"), main)); + var storage = groups(machine, node, IoMode.BOTH, 3); + if (!storage.isEmpty()) { + storage.addFirst(spacer(node)); + storage.addLast(spacer(node)); + rows.add(n(node, "hbox", Map.of("spacing", "6"), storage)); + } + rows.add(spacer(node)); + // The base template supplies the matching spacer before its energy gauge. + return List.of(spacer(node), n(node, "vbox", Map.of("spacing", "4"), rows)); + } + + private static List resources(MachineDefinition machine, Node node) { + options(node, Set.of("role", "columns")); + String role = MachineDefinitions.required(node, "role"); + IoMode mode = + switch (role) { + case "input" -> IoMode.INPUT; + case "output" -> IoMode.OUTPUT; + case "storage" -> IoMode.BOTH; + default -> throw MachineDefinitions.error(node, "Unknown group role " + role); + }; + int columns; + try { + columns = Integer.parseInt(node.attributes().getOrDefault("columns", "3")); + } catch (NumberFormatException ex) { + throw MachineDefinitions.error(node, "columns must be 1..9"); + } + if (columns < 1 || columns > 9) throw MachineDefinitions.error(node, "columns must be 1..9"); + return groups(machine, node, mode, columns); + } + + private static List groups(MachineDefinition machine, Node node, IoMode mode, int columns) { + var result = new ArrayList(); + for (var group : machine.groups()) + if (group.mode() == mode || (mode == IoMode.BOTH && group.mode() == IoMode.DISABLED)) { + var rows = new ArrayList(); + rows.add(spacer(node)); + for (int start = 0; start < group.members().size(); start += columns) { + var row = new ArrayList(); + for (int j = start; j < Math.min(start + columns, group.members().size()); j++) { + int index = group.members().get(j); + row.add( + n( + node, + "slot", + Map.of("id", "slot_" + index, "index", Integer.toString(index)), + List.of())); + } + rows.add(n(node, "hbox", Map.of("spacing", "2"), row)); + } + rows.add(spacer(node)); + result.add(n(node, "vbox", Map.of("spacing", "2"), rows)); + } + return result; + } + + private static Node n( + Node origin, String name, Map attributes, List children) { + return new Node(name, attributes, List.copyOf(children), origin.source(), origin.line()); + } + + private static Node spacer(Node origin) { + return n(origin, "spacer", Map.of(), List.of()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java index 1bc0af44..77d41539 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java @@ -189,6 +189,14 @@ protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries /** Additional machine metadata; never called for a feature-only client update. */ protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) {} + /** + * Maps old numeric item/tank indices before storage and processor restoration. Stable IDs bypass + * it. + */ + protected java.util.function.IntUnaryOperator legacySlots(String key, CompoundTag tag) { + return java.util.function.IntUnaryOperator.identity(); + } + /** * Writes extra client state into a dedicated compound, separate from storage and feature data. */ @@ -227,7 +235,7 @@ protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider regis } super.loadAdditional(tag, registries); var inventory = configuredInventory(); - if (inventory != null) inventory.load(tag, registries); + if (inventory != null) inventory.load(tag, registries, this::legacySlots); else { features.reset(); if (tag.contains("energy")) energy.deserializeNBT(registries, tag.get("energy")); 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 new file mode 100644 index 00000000..a00eed11 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineBlock.java @@ -0,0 +1,116 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.function.Supplier; + +import net.minecraft.core.*; +import net.minecraft.world.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.*; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.*; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.entity.*; +import net.minecraft.world.level.block.state.*; +import net.minecraft.world.level.block.state.properties.*; +import net.minecraft.world.phys.BlockHitResult; + +import zone.moddev.mc.lib.common.runtime.DropsContents; + +/** Standard placement, interaction and lifecycle hooks for XML machines. */ +public final class DefinedMachineBlock extends Block implements EntityBlock { + private final Supplier> type; + + public DefinedMachineBlock( + Properties properties, Supplier> type) { + super(properties); + this.type = type; + registerDefaultState( + stateDefinition.any().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(BlockStateProperties.HORIZONTAL_FACING); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + return defaultBlockState() + .setValue( + BlockStateProperties.HORIZONTAL_FACING, context.getHorizontalDirection().getOpposite()); + } + + @Override + public BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue( + BlockStateProperties.HORIZONTAL_FACING, + rotation.rotate(state.getValue(BlockStateProperties.HORIZONTAL_FACING))); + } + + @Override + public BlockState mirror(BlockState state, Mirror mirror) { + return rotate( + state, mirror.getRotation(state.getValue(BlockStateProperties.HORIZONTAL_FACING))); + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return type.get().create(pos, state); + } + + @Override + public BlockEntityTicker getTicker( + Level level, BlockState state, BlockEntityType requested) { + return level.isClientSide() || requested != type.get() + ? null + : (world, pos, s, entity) -> ((DefinedMachineEntity) entity).serverTick(); + } + + @Override + protected InteractionResult useWithoutItem( + BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + return level.getBlockEntity(pos) instanceof DefinedMachineEntity machine + && machine.openScreen(player) + ? InteractionResult.sidedSuccess(level.isClientSide()) + : InteractionResult.PASS; + } + + @Override + protected ItemInteractionResult useItemOn( + ItemStack stack, + BlockState state, + Level level, + BlockPos pos, + Player player, + InteractionHand hand, + BlockHitResult hit) { + if (stack.is(MachineContent.recoveryItem()) + && level.getBlockEntity(pos) instanceof DefinedMachineEntity machine) { + if (level.isClientSide()) return ItemInteractionResult.SUCCESS; + return MachineRecoveryItem.restore(stack, machine) + ? ItemInteractionResult.CONSUME + : ItemInteractionResult.FAIL; + } + return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION; + } + + @Override + protected void onRemove( + BlockState state, Level level, BlockPos pos, BlockState replacement, boolean moved) { + DropsContents.onRemove(state, level, pos, replacement); + super.onRemove(state, level, pos, replacement, moved); + } + + @Override + protected boolean isSignalSource(BlockState state) { + return true; + } + + @Override + protected int getSignal(BlockState state, BlockGetter level, BlockPos pos, Direction side) { + return level.getBlockEntity(pos) instanceof DefinedMachineEntity machine + && machine.screenRedstone() != null + ? machine.screenRedstone().signal(side) + : 0; + } +} 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 new file mode 100644 index 00000000..7c916abc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/DefinedMachineEntity.java @@ -0,0 +1,139 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.*; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +import zone.moddev.mc.lib.api.automation.*; +import zone.moddev.mc.lib.api.capabilities.*; +import zone.moddev.mc.lib.api.redstone.RedstoneControl; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation; +import zone.moddev.mc.lib.machine.definition.MachineDefinition; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** Shared runtime assembled from XML. There is no per-machine Java subclass. */ +public final class DefinedMachineEntity extends BasePoweredMachine { + public final MachineDefinition definition; + public final ItemStorage items; + public final MachineResources resources; + public final MachineInventoryLifecycle inventory; + private final RedstoneControl redstone; + + public DefinedMachineEntity( + BlockEntityType type, + BlockPos pos, + BlockState state, + MachineDefinition definition, + MachineRecipeType recipes) { + super( + type, + pos, + state, + definition.power().capacity(), + definition.power().input(), + definition.power().output()); + this.definition = definition; + items = new ItemStorage(definition.slots().size()).onChange(this::setChanged); + for (int i = 0; i < definition.slots().size(); i++) { + var slot = definition.slots().get(i); + items.slotId(i, slot.id()).role(slot.role(), i).limit(slot.limit(), i); + } + resources = new MachineResources(this, this::facing); + var screen = Screens.get(definition.id()).orElseThrow().screen(); + for (var group : definition.groups()) { + var highlights = + screen.elements().stream() + .filter(e -> e instanceof Element.Slot s && group.members().contains(s.index())) + .flatMap(e -> e.common().id().stream()) + .toList(); + resources.items( + new ResourceGroup<>( + group.id(), + Component.literal(group.id()), + group.color(), + ResourceType.ITEMS, + group.mode(), + group.members(), + highlights, + new SideConfig(group.mode())), + items); + } + var energyHighlights = + screen.elements().stream() + .filter(e -> e instanceof Element.EnergyGauge) + .flatMap(e -> e.common().id().stream()) + .toList(); + resources.energy( + new ResourceGroup<>( + "energy", + Component.literal("Energy"), + definition.power().color(), + ResourceType.ENERGY, + IoMode.INPUT, + List.of(0), + energyHighlights, + new SideConfig(IoMode.INPUT)), + energy); + for (var group : definition.groups()) + if (group.mode() == IoMode.INPUT) resources.recipeInputs(recipes, group.id()); + resources.build(); + features.add( + new RecipeProcessor( + definition.processor(), + recipes, + resources.recipes(), + Component.literal(definition.processorName()))); + inventory = poweredInventory(resources.automation()).items(definition.itemStorageId(), items); + redstone = definition.redstone() ? new RedstoneControl(this, List.of(features)) : null; + ScreenInventoryValidation.validate(this); + } + + @Override + public String screenDefinitionFingerprint() { + return definition.checksum(); + } + + @Override + public ItemStorage screenItems() { + return items; + } + + @Override + public RedstoneControl screenRedstone() { + return redstone; + } + + @Override + protected MachineInventoryLifecycle machineInventory() { + return inventory; + } + + @Override + public int[] screenRecipeSlots(ResourceLocation category) { + if (!definition.recipeType().id().equals(category)) return new int[0]; + return definition.groups().stream() + .filter(g -> g.mode() == IoMode.INPUT) + .flatMap(g -> g.members().stream()) + .mapToInt(Integer::intValue) + .toArray(); + } + + @Override + protected void recoverResources(Level level, BlockPos pos, CompoundTag payload) { + net.minecraft.world.Containers.dropItemStack( + level, + pos.getX() + .5, + pos.getY() + .5, + pos.getZ() + .5, + MachineRecoveryItem.create(payload)); + } +} 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 new file mode 100644 index 00000000..5e7ea469 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/GeneratedMachineRecipes.java @@ -0,0 +1,233 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.lang.reflect.Constructor; +import java.util.*; + +import com.mojang.serialization.JsonOps; + +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.*; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.crafting.RecipeHolder; + +import net.neoforged.neoforge.event.AddReloadListenerEvent; + +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.*; + +/** + * 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 static final Map providers = new TreeMap<>(); + + 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); + } + }); + } + + 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))); + } + }); + } + + /** + * Builds a fresh candidate; exposed to runtime tests without altering the active recipe manager. + */ + public static List> generate( + List> authored, + net.minecraft.core.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 = registries.createSerializationContext(JsonOps.INSTANCE); + 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()); + } + + 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()); + } + + 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 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())); + recipe + .inputs() + .forEach( + (key, parts) -> { + if (!items.contains(key) && !key.equals(machine.power().id())) + 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.EnergyPart + && key.equals(machine.power().id()); + if (!valid) + throw new IllegalArgumentException( + machine.id() + ": incompatible recipe input group " + key); + }); + }); + recipe + .outputs() + .forEach( + (key, parts) -> { + if (!items.contains(key)) + throw new IllegalArgumentException( + machine.id() + ": unknown recipe output group " + key); + parts.forEach( + part -> { + if (!(part instanceof MachineProduct.ItemProduct) || !items.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 new file mode 100644 index 00000000..b43857d8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineContent.java @@ -0,0 +1,183 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.*; + +import net.minecraft.core.registries.*; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.*; +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.capabilities.*; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.registries.RegisterEvent; + +import zone.moddev.mc.lib.api.automation.ResourceType; +import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.common.loading.DefinitionLoader.SourceRoot; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.gui.load.ScreenLoader; +import zone.moddev.mc.lib.machine.definition.*; +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** Library-owned registration for concrete definitions in any installed mod namespace. */ +public final class MachineContent { + public static final ResourceLocation RECOVERY = + ResourceLocation.parse("mmdlib:machine_recovery_capsule"); + private static Map definitions = Map.of(); + private static final Map recipes = new TreeMap<>(); + private static final Map blocks = new TreeMap<>(); + private static final Map> entities = + new TreeMap<>(); + + private MachineContent() {} + + public static void register(IEventBus bus) { + bus.addListener(MachineContent::registries); + bus.addListener(MachineContent::capabilities); + bus.addListener(MachineResourcePack::register); + NeoForge.EVENT_BUS.addListener(GeneratedMachineRecipes::register); + } + + public static Map definitions() { + return definitions; + } + + public static MachineRecipeType recipeType(ResourceLocation id) { + var type = recipes.get(id); + if (type == null) throw new IllegalArgumentException("Unknown machine recipe type " + id); + return type; + } + + public static Item recoveryItem() { + return BuiltInRegistries.ITEM.get(RECOVERY); + } + + public static void load(List roots) { + definitions = MachineDefinitions.load(roots); + var declarations = new TreeMap(); + for (var machine : definitions.values()) { + var type = machine.recipeType(); + if (type.declare()) { + var previous = declarations.putIfAbsent(type.id(), type); + if (previous != null && !same(previous, type)) + throw new IllegalArgumentException("Conflicting recipe declarations " + type.id()); + } + } + declarations.forEach((id, type) -> recipes.put(id, new MachineRecipeType(id))); + for (var machine : definitions.values()) + if (!recipes.containsKey(machine.recipeType().id())) + throw new IllegalArgumentException( + machine.id() + + ": recipe type must be declared in machine XML: " + + machine.recipeType().id()); + GeneratedMachineRecipes.configure(declarations); + var embedded = new TreeMap(); + definitions.forEach((id, machine) -> embedded.put(id, machine.screen())); + var screens = + ScreenLoader.load( + roots, + embedded, + (id, node) -> MachineScreenGenerators.generate(definitions.get(id), node)); + definitions.forEach( + (id, machine) -> MachineScreenGenerators.validate(machine, screens.get(id).screen())); + Screens.install(screens); + } + + private static boolean same(MachineDefinition.RecipeType a, MachineDefinition.RecipeType b) { + if (!a.name().equals(b.name())) return false; + if (a.generator() == null || b.generator() == null) return a.generator() == b.generator(); + return a.generator().className().equals(b.generator().className()) + && a.generator().properties().equals(b.generator().properties()) + && a.generator().loader() == b.generator().loader(); + } + + private static void registries(RegisterEvent event) { + event.register( + Registries.RECIPE_TYPE, + helper -> helper.register(MachineRecipeBundle.ID, MachineRecipeBundle.TYPE)); + event.register( + Registries.RECIPE_SERIALIZER, + helper -> helper.register(MachineRecipeBundle.ID, MachineRecipeBundle.SERIALIZER)); + event.register( + Registries.RECIPE_TYPE, helper -> recipes.forEach((id, type) -> helper.register(id, type))); + event.register( + Registries.RECIPE_SERIALIZER, + helper -> recipes.forEach((id, type) -> helper.register(id, type.serializer()))); + event.register( + Registries.BLOCK, + helper -> + definitions.forEach( + (id, definition) -> { + var properties = BlockBehaviour.Properties.of().strength(definition.hardness()); + if (definition.requiresTool()) properties.requiresCorrectToolForDrops(); + var block = new DefinedMachineBlock(properties, () -> entities.get(id)); + helper.register(id, block); + blocks.put(id, block); + })); + event.register( + Registries.ITEM, + helper -> { + helper.register(RECOVERY, new MachineRecoveryItem()); + blocks.forEach( + (id, block) -> helper.register(id, new BlockItem(block, new Item.Properties()))); + }); + event.register( + Registries.BLOCK_ENTITY_TYPE, + helper -> + definitions.forEach( + (id, definition) -> { + var type = + BlockEntityType.Builder.of( + (pos, state) -> + new DefinedMachineEntity( + entities.get(id), + pos, + state, + definition, + recipeType(definition.recipeType().id())), + blocks.get(id)) + .build(null); + helper.register(id, type); + entities.put(id, type); + })); + event.register( + Registries.CREATIVE_MODE_TAB, + helper -> { + if (!definitions.isEmpty()) + helper.register( + ResourceLocation.parse("mmdlib:data_machines"), + CreativeModeTab.builder() + .title(Component.literal("Data-defined Machines")) + .icon(() -> new ItemStack(blocks.values().iterator().next())) + .displayItems( + (parameters, output) -> { + blocks.values().forEach(output::accept); + output.accept(recoveryItem()); + }) + .build()); + }); + } + + private static void capabilities(RegisterCapabilitiesEvent event) { + for (var type : entities.values()) { + event.registerBlockEntity( + Capabilities.ItemHandler.BLOCK, + type, + (entity, side) -> + entity.items.view( + index -> entity.resources.automation().mode(ResourceType.ITEMS, index, side))); + event.registerBlockEntity( + Capabilities.EnergyStorage.BLOCK, + type, + (entity, side) -> + entity.energy.view( + () -> + side == null + ? IoMode.INPUT + : entity.resources.automation().mode(ResourceType.ENERGY, 0, side))); + } + } +} 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 68f307af..6d88c707 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 @@ -1,7 +1,9 @@ package zone.moddev.mc.lib.machine.neoforge; import java.util.*; +import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.IntUnaryOperator; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; @@ -17,6 +19,17 @@ * recovery. */ public final class MachineInventoryLifecycle { + private static final Set RESERVED_KEYS = + Set.of( + "automation", + "features", + "redstone_control", + "redstone_mode", + "id", + "components", + "mmdlib_features", + "mmdlib_machine"); + public enum PausePolicy { PROCESSING_ONLY(true, false), TRANSFERS_ONLY(false, true), @@ -80,8 +93,7 @@ private MachineInventoryLifecycle add(String key, Object storage) { Objects.requireNonNull(storage); if (key == null || key.isBlank() - || key.equals("automation") - || key.equals("features") + || RESERVED_KEYS.contains(key) || storages.containsKey(key) || storages.containsValue(storage)) throw new IllegalArgumentException("Duplicate or reserved storage key: " + key); @@ -102,13 +114,21 @@ else if (storage instanceof FluidStorage fluids) } public void load(CompoundTag tag, HolderLookup.Provider registries) { + load(tag, registries, (key, saved) -> IntUnaryOperator.identity()); + } + + /** Resolves pre-ID item/tank index mappings from each storage key and the complete world save. */ + public void load( + CompoundTag tag, + HolderLookup.Provider registries, + BiFunction legacySlots) { features.reset(); storages.forEach( (key, storage) -> { if (storage instanceof ItemStorage items) - items.deserializeNBT(registries, tag.getCompound(key)); + items.deserializeNBT(registries, tag.getCompound(key), legacySlots.apply(key, tag)); else if (storage instanceof FluidStorage fluids) - fluids.deserializeNBT(registries, tag.getCompound(key)); + fluids.deserializeNBT(registries, tag.getCompound(key), legacySlots.apply(key, tag)); else if (tag.contains(key)) ((EnergyBuffer) storage).deserializeNBT(registries, tag.get(key)); }); @@ -146,12 +166,12 @@ public void dropContents(Consumer recoverResources) { var level = owner.getLevel(); if (level == null || level.isClientSide) return; for (var storage : storages.values()) { - if (storage instanceof ItemStorage items) items.recover(recoverResources); - else if (storage instanceof FluidStorage fluids) fluids.recover(recoverResources); + if (storage instanceof FluidStorage fluids) fluids.recover(recoverResources); } automation.dropAll(recoverResources); features.reset(); for (var storage : storages.values()) - if (storage instanceof ItemStorage items) items.dropAll(level, owner.getBlockPos()); + if (storage instanceof ItemStorage items) + items.dropAll(level, owner.getBlockPos(), recoverResources); } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeBundle.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeBundle.java new file mode 100644 index 00000000..306faef9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecipeBundle.java @@ -0,0 +1,94 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.*; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; + +import net.minecraft.core.HolderLookup; +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.*; +import net.minecraft.world.level.Level; + +import net.neoforged.neoforge.common.conditions.WithConditions; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipeInput; + +/** Authoring container expanded into ordinary machine recipes before publication and sync. */ +public record MachineRecipeBundle(Map>>> recipes) + implements Recipe { + public static final ResourceLocation ID = ResourceLocation.parse("mmdlib:machine_recipes"); + public static final RecipeType TYPE = + new RecipeType<>() { + @Override + public String toString() { + return ID.toString(); + } + }; + private static final Codec KEY = + Codec.STRING.validate( + key -> + !key.isEmpty() && ResourceLocation.tryBuild("mmdlib", key) != null + ? DataResult.success(key) + : DataResult.error(() -> "Invalid recipe entry name: " + key)); + public static final MapCodec CODEC = + Codec.unboundedMap(KEY, Recipe.CONDITIONAL_CODEC) + .fieldOf("recipes") + .xmap(MachineRecipeBundle::new, MachineRecipeBundle::recipes); + public static final RecipeSerializer SERIALIZER = + new RecipeSerializer<>() { + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public StreamCodec streamCodec() { + return ByteBufCodecs.fromCodecWithRegistries(CODEC.codec()); + } + }; + + public MachineRecipeBundle { + recipes = Collections.unmodifiableMap(new TreeMap<>(recipes)); + } + + @Override + public boolean matches(MachineRecipeInput input, Level level) { + return false; + } + + @Override + public ItemStack assemble(MachineRecipeInput input, HolderLookup.Provider registries) { + return ItemStack.EMPTY; + } + + @Override + public boolean canCraftInDimensions(int width, int height) { + return false; + } + + @Override + public ItemStack getResultItem(HolderLookup.Provider registries) { + return ItemStack.EMPTY; + } + + @Override + public boolean isSpecial() { + return true; + } + + @Override + public RecipeSerializer getSerializer() { + return SERIALIZER; + } + + @Override + public RecipeType getType() { + return TYPE; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java new file mode 100644 index 00000000..8502c3c7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java @@ -0,0 +1,76 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.*; +import net.minecraft.world.item.*; +import net.minecraft.world.item.component.CustomData; + +import zone.moddev.mc.lib.api.automation.ResourceType; +import zone.moddev.mc.lib.api.capabilities.Transfer; + +/** Durable displaced-resource payload for data-defined machines. Unknown entries are retained. */ +public final class MachineRecoveryItem extends Item { + private static final String KEY = "mmdlib_recovery"; + + public MachineRecoveryItem() { + super(new Item.Properties().stacksTo(1)); + } + + public static ItemStack create(CompoundTag payload) { + var stack = new ItemStack(MachineContent.recoveryItem()); + var data = new CompoundTag(); + data.put(KEY, payload.copy()); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data)); + return stack; + } + + public static boolean restore(ItemStack stack, DefinedMachineEntity target) { + if (target.getLevel() == null + || target.getLevel().isClientSide() + || !stack.is(MachineContent.recoveryItem())) return false; + var outer = stack.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY).copyTag(); + var payload = outer.getCompound(KEY); + var entries = payload.getList("resources", Tag.TAG_COMPOUND); + boolean changed = false; + for (int i = entries.size() - 1; i >= 0; i--) { + var entry = entries.getCompound(i); + var data = entry.getCompound("data"); + String type = entry.getString("type"); + if (type.equals(ResourceType.ITEMS.id().toString())) { + var item = + ItemStack.parseOptional(target.getLevel().registryAccess(), data.getCompound("item")); + if (!item.isEmpty()) { + var remaining = Transfer.insert(target.items, item, false); + if (remaining.getCount() < item.getCount()) { + changed = true; + if (remaining.isEmpty()) data.remove("item"); + else data.put("item", remaining.save(target.getLevel().registryAccess())); + } + } + } else if (type.equals(ResourceType.ENERGY.id().toString())) { + long amount = Math.max(0, data.getLong("energy")), + moved = target.energy.insert(amount, false); + if (moved > 0) { + changed = true; + if (moved == amount) data.remove("energy"); + else data.putLong("energy", amount - moved); + } + } + if (data.isEmpty()) entries.remove(i); + } + long refund = Math.max(0, payload.getLong("refund")), + moved = target.energy.insert(refund, false); + if (moved > 0) changed = true; + if (!changed) return false; + if (moved == refund) payload.remove("refund"); + else payload.putLong("refund", refund - moved); + if (entries.isEmpty()) payload.remove("resources"); + if (payload.isEmpty()) stack.shrink(1); + else { + outer.put(KEY, payload); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(outer)); + } + target.setChanged(); + return true; + } +} 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 new file mode 100644 index 00000000..d0ca5571 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineResourcePack.java @@ -0,0 +1,189 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import com.google.gson.*; + +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.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); + } + + 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) { + 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); + } + 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); + } + + @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() {} +} 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 fea9d02a..87d2cc68 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 @@ -73,7 +73,7 @@ private void check(ResourceGroup group, int size, java.util.function.IntFunct } private void add(ResourceGroup group, Object storage) { - if (bindings.containsKey(group.id())) throw invalid(group.id(), "duplicate ID"); + if (recipeBindings.containsKey(group.id())) throw invalid(group.id(), "duplicate ID"); // Membership is indexed by resource type, so a type must share one physical index space. for (var binding : bindings.values()) { if (binding.group.type() != group.type()) continue; @@ -92,6 +92,7 @@ public MachineResources recipeGroup(String key, String... groupIds) { ensureOpen(); if (key == null || key.isBlank() || groupIds.length == 0) throw invalid(key, "empty recipe mapping"); + if (recipeBindings.containsKey(key)) throw invalid(key, "duplicate recipe binding"); var selected = new ArrayList(); for (String id : groupIds) { var binding = bindings.get(id); diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/RecipeInputFilters.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/RecipeInputFilters.java index 4a77c8c0..42bcb022 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/RecipeInputFilters.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/RecipeInputFilters.java @@ -1,10 +1,15 @@ package zone.moddev.mc.lib.machine.neoforge; -import java.util.List; +import java.util.*; import java.util.function.Supplier; +import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; import net.minecraft.world.level.Level; +import net.minecraft.world.level.material.Fluid; import net.neoforged.neoforge.fluids.FluidStack; @@ -20,6 +25,17 @@ public record Link(MachineRecipeType type, String group) {} private final Supplier level; private final MachineInventory inventory; + private RecipeManager manager; + private Collection> snapshot; + private final Map> recipes = new HashMap<>(); + private final Map accepted = new HashMap<>(); + + private record Accepted(Set items, List custom, Set fluids) { + boolean accepts(ItemStack stack) { + return items.contains(stack.getItem()) + || custom.stream().anyMatch(ingredient -> ingredient.test(stack)); + } + } public RecipeInputFilters(Supplier level, MachineInventory inventory) { this.level = level; @@ -27,32 +43,59 @@ public RecipeInputFilters(Supplier level, MachineInventory inventory) { } public boolean acceptsItem(List links, ItemStack stack) { - return inputs(links) - .anyMatch( - part -> part instanceof MachineIngredient.ItemPart item && item.items().test(stack)); + if (!refresh()) return false; + for (var link : links) + if (accepted.computeIfAbsent(link, this::index).accepts(stack)) return true; + return false; } public boolean acceptsFluid(List links, FluidStack stack) { - return inputs(links) - .anyMatch( - part -> - part instanceof MachineIngredient.FluidPart fluid - && fluid.fluids().contains(stack.getFluid().builtInRegistryHolder())); + if (!refresh()) return false; + for (var link : links) + if (accepted.computeIfAbsent(link, this::index).fluids().contains(stack.getFluid())) + return true; + return false; } - private java.util.stream.Stream inputs(List links) { + private boolean refresh() { var world = level.get(); - if (world == null) return java.util.stream.Stream.empty(); - // Consult the manager each time: replacements from server reload and client recipe sync apply - // immediately. - return links.stream() - .flatMap( - link -> - link.type.recipes(world.getRecipeManager()).stream() + if (world == null) return false; + var current = world.getRecipeManager(); + // 1.21.1 exposes its immutable by-name map's values without copying. apply/replaceRecipes + // replace that map on server reload and client synchronization, even when IDs stay the same. + var currentSnapshot = current.getRecipes(); + if (manager != current || snapshot != currentSnapshot) { + manager = current; + snapshot = currentSnapshot; + recipes.clear(); + accepted.clear(); + } + return true; + } + + private Accepted index(Link link) { + var items = new HashSet(); + var custom = new ArrayList(); + var fluids = new HashSet(); + var matching = + recipes.computeIfAbsent( + link.type(), + type -> + type.recipes(manager).stream() .map(holder -> holder.value().recipe()) .filter(this::compatible) - .flatMap( - recipe -> recipe.inputs().getOrDefault(link.group, List.of()).stream())); + .toList()); + for (var recipe : matching) + for (var part : recipe.inputs().getOrDefault(link.group(), List.of())) { + if (part instanceof MachineIngredient.ItemPart item) { + var ingredient = item.items(); + if (ingredient.isSimple()) { + for (var stack : ingredient.getItems()) items.add(stack.getItem()); + } else custom.add(ingredient); + } else if (part instanceof MachineIngredient.FluidPart fluid) + fluid.fluids().forEach(holder -> fluids.add(holder.value())); + } + return new Accepted(Set.copyOf(items), List.copyOf(custom), Set.copyOf(fluids)); } private boolean compatible(MachineRecipe recipe) { 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 12a995b6..54ddf577 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 @@ -221,7 +221,6 @@ public int insert(FluidAmount fluid, MachineTx tx) { @Override public int insert(int tank, FluidAmount fluid, MachineTx tx) { if (!valid(tank) || fluid.isEmpty()) return 0; - var handler = storage.tank(indexes[tank]); var stack = FluidStacks.stack(fluid); if (storage.fillResult(indexes[tank], stack, FluidAction.SIMULATE) <= 0) return 0; track(indexes[tank], tx); diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java new file mode 100644 index 00000000..7ef2f6a8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import java.util.*; + +import zone.moddev.mc.lib.gui.load.ScreenException; + +/** Immutable XML attributes with source-aware typed access. The class attribute is excluded. */ +public record GeneratorProperties(Map values, String source, int line) { + public GeneratorProperties { + values = Map.copyOf(values); + } + + public void requireOnly(String... names) { + var allowed = Set.of(names); + for (String name : values.keySet()) + if (!allowed.contains(name)) throw error("Unknown generator property " + name); + } + + public String requiredString(String name) { + var value = values.get(name); + if (value == null || value.isBlank()) throw error("Missing generator property " + name); + return value; + } + + public String string(String name, String fallback) { + return values.containsKey(name) ? requiredString(name) : fallback; + } + + public int requiredInt(String name, int min, int max) { + return (int) requiredLong(name, min, max); + } + + public int integer(String name, int fallback, int min, int max) { + return values.containsKey(name) ? requiredInt(name, min, max) : fallback; + } + + public long longValue(String name, long fallback, long min, long max) { + return values.containsKey(name) ? requiredLong(name, min, max) : fallback; + } + + public double decimal(String name, double fallback, double min, double max) { + if (!values.containsKey(name)) return fallback; + try { + double value = Double.parseDouble(requiredString(name)); + if (Double.isFinite(value) && value >= min && value <= max) return value; + } catch (NumberFormatException ex) { + /* source-aware error below */ + } + throw error(name + " must be finite and in " + min + ".." + max); + } + + public long requiredLong(String name, long min, long max) { + try { + long value = Long.parseLong(requiredString(name)); + if (value >= min && value <= max) return value; + } catch (NumberFormatException ex) { + /* error below */ + } + throw error(name + " must be in " + min + ".." + max); + } + + public IllegalArgumentException error(String message) { + return new ScreenException(source, line, message); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeGenerator.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeGenerator.java new file mode 100644 index 00000000..29fa92ba --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeGenerator.java @@ -0,0 +1,6 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +/** Implement with a public no-argument constructor; one new instance per type per data reload. */ +public interface MachineRecipeGenerator { + void generate(RecipeGenerationContext context, MachineRecipeOutput output); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeOutput.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeOutput.java new file mode 100644 index 00000000..3d1317fc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/MachineRecipeOutput.java @@ -0,0 +1,11 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import net.minecraft.resources.ResourceLocation; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; + +/** Stages a definition for the context's type. Does not mutate a live inventory or write files. */ +@FunctionalInterface +public interface MachineRecipeOutput { + void accept(ResourceLocation id, MachineRecipe recipe); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java new file mode 100644 index 00000000..f83fa0e4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java @@ -0,0 +1,181 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import java.util.*; + +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.block.Block; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; + +/** Reusable ore processing: joins nonempty c:ores/material and c:ingots/material item tags. */ +public final class OreToIngotGenerator implements MachineRecipeGenerator { + public static final int INPUT_COUNT = 1; + public static final int OUTPUT_COUNT = 2; + public static final int DEFAULT_TIME = 100; + public static final long DEFAULT_ENERGY = 2000; + public static final String DEFAULT_PREFERRED_MODS = "minecraft;mmdlib"; + + public OreToIngotGenerator() {} + + @Override + public void generate(RecipeGenerationContext ctx, MachineRecipeOutput output) { + var p = ctx.properties(); + p.requireOnly( + "input-group", + "output-group", + "energy-group", + "time", + "energy", + "preferred-mods", + "tool-level-ratio", + "hardness-level-ratio"); + String input = p.string("input-group", "input"), + result = p.string("output-group", "output"), + power = p.string("energy-group", "energy"); + int ticks = p.integer("time", DEFAULT_TIME, 1, Integer.MAX_VALUE); + long energy = p.longValue("energy", DEFAULT_ENERGY, 1, Long.MAX_VALUE); + double toolRatio = p.decimal("tool-level-ratio", 0.0, 0.0, Double.MAX_VALUE); + double hardnessRatio = p.decimal("hardness-level-ratio", 0.0, 0.0, Double.MAX_VALUE); + var preferred = preferredMods(p); + Comparator> order = + Comparator.comparingInt( + (Holder h) -> + preferred.getOrDefault( + BuiltInRegistries.ITEM.getKey(h.value()).getNamespace(), Integer.MAX_VALUE)) + .thenComparing(h -> BuiltInRegistries.ITEM.getKey(h.value())); + for (var tag : ctx.tags().items().keySet()) { + if (!tag.getNamespace().equals("c") + || !tag.getPath().startsWith("ores/") + || ctx.tags().members(tag).isEmpty()) continue; + String metal = tag.getPath().substring("ores/".length()); + if (metal.isEmpty()) continue; + var ingots = + ctx.tags().members(ResourceLocation.fromNamespaceAndPath("c", "ingots/" + metal)); + if (ingots.isEmpty()) continue; + var stack = new ItemStack(ingots.stream().min(order).orElseThrow().value(), OUTPUT_COUNT); + if (OUTPUT_COUNT > stack.getMaxStackSize()) + throw p.error("Output count exceeds stack limit for " + metal); + var id = + ResourceLocation.fromNamespaceAndPath( + ctx.recipeType().id().getNamespace(), + "generated/" + ctx.recipeType().id().getPath() + "/ores/" + metal); + if (toolRatio == 0 && hardnessRatio == 0) { + emit( + ctx, + output, + id, + input, + result, + power, + ctx.tags().ingredient(tag), + stack, + ticks, + energy); + continue; + } + // Each ore variant keeps its own cost and stable ID when block scaling is enabled. + for (var ore : + ctx.tags().members(tag).stream() + .sorted(Comparator.comparing(h -> BuiltInRegistries.ITEM.getKey(h.value()))) + .toList()) { + var item = ore.value(); + double hardness = 0; + int level = 0; + if (item instanceof BlockItem blockItem) { + var block = blockItem.getBlock(); + hardness = block.defaultDestroyTime(); + if (hardness < 0) continue; // Unbreakable blocks cannot be processed by mining cost. + if (!Double.isFinite(hardness)) + throw p.error("Invalid block hardness: " + BuiltInRegistries.ITEM.getKey(item)); + level = toolLevel(ctx.tags(), block); + } + double factor = 1.0 + level * toolRatio + hardness * hardnessRatio; + var oreId = BuiltInRegistries.ITEM.getKey(item); + var recipeId = + ResourceLocation.fromNamespaceAndPath( + id.getNamespace(), + id.getPath() + "/" + oreId.getNamespace() + "/" + oreId.getPath()); + emit( + ctx, + output, + recipeId, + input, + result, + power, + Ingredient.of(item), + stack, + (int) scaled(ticks, factor, Integer.MAX_VALUE, p, "time"), + scaled(energy, factor, Long.MAX_VALUE, p, "energy")); + } + } + } + + private static Map preferredMods(GeneratorProperties p) { + var result = new LinkedHashMap(); + for (String part : p.string("preferred-mods", DEFAULT_PREFERRED_MODS).split(";", -1)) { + String namespace = part.trim(); + if (!namespace.matches("[a-z0-9_.-]+")) + throw p.error("preferred-mods must be a ';'-separated list of namespaces"); + if (result.putIfAbsent(namespace, result.size()) != null) + throw p.error("Duplicate preferred mod " + namespace); + } + return result; + } + + private static long scaled( + long base, double factor, long max, GeneratorProperties p, String name) { + // Preserve exact unscaled long costs, including Long.MAX_VALUE. + if (factor == 1.0) return base; + double value = base * factor; + if (Double.isFinite(value)) { + double nearest = Math.rint(value); + // Ignore a few rounding steps at an integer boundary (100 * 1.1 is just above 110). + double rounded = + Math.abs(value - nearest) <= 4 * Math.ulp(value) ? nearest : Math.ceil(value); + // A double comparison with Long.MAX_VALUE would round that bound up to 2^63. + if (rounded >= 1 && rounded < 0x1.0p63 && rounded <= max) return (long) rounded; + } + throw p.error("Scaled " + name + " exceeds 1.." + max); + } + + private static int toolLevel(ReloadTags tags, Block block) { + if (!block.defaultBlockState().requiresCorrectToolForDrops()) return 0; + if (tags.contains(BlockTags.INCORRECT_FOR_NETHERITE_TOOL, block)) return 5; + if (tags.contains(BlockTags.INCORRECT_FOR_DIAMOND_TOOL, block)) return 4; + if (tags.contains(BlockTags.NEEDS_DIAMOND_TOOL, block) + || tags.contains(BlockTags.INCORRECT_FOR_IRON_TOOL, block)) return 3; + if (tags.contains(BlockTags.NEEDS_IRON_TOOL, block) + || tags.contains(BlockTags.INCORRECT_FOR_STONE_TOOL, block)) return 2; + if (tags.contains(BlockTags.NEEDS_STONE_TOOL, block) + || tags.contains(BlockTags.INCORRECT_FOR_WOODEN_TOOL, block)) return 1; + return 0; + } + + private static void emit( + RecipeGenerationContext ctx, + MachineRecipeOutput output, + ResourceLocation id, + String input, + String result, + String power, + Ingredient ingredient, + ItemStack product, + int ticks, + long energy) { + output.accept( + id, + MachineRecipeBuilder.recipe(ctx.recipeType()) + .inputItems(input, ingredient, INPUT_COUNT) + .inputEnergy(power, energy) + .outputItem(result, product.copy()) + .time(ticks) + .build()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java new file mode 100644 index 00000000..5ef6e159 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java @@ -0,0 +1,20 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import java.util.List; + +import net.minecraft.core.HolderLookup; +import net.minecraft.world.item.crafting.RecipeHolder; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; + +/** Reload data for a recipe type shared by all its machines; no live machine inventory. */ +public record RecipeGenerationContext( + MachineRecipeType recipeType, + HolderLookup.Provider registries, + ReloadTags tags, + List> recipes, + GeneratorProperties properties) { + public RecipeGenerationContext { + recipes = List.copyOf(recipes); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/ReloadTags.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/ReloadTags.java new file mode 100644 index 00000000..bc64df16 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/ReloadTags.java @@ -0,0 +1,45 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import java.util.*; + +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.block.Block; + +/** Snapshot of staged item/block tags, never stale globally bound tags during reload. */ +public record ReloadTags( + Map>> items, + Map>> blocks) { + public ReloadTags { + items = snapshot(items); + blocks = snapshot(blocks); + } + + public ReloadTags(Map>> items) { + this(items, Map.of()); + } + + private static Map>> snapshot( + Map>> tags) { + var copy = new TreeMap>>(); + tags.forEach((id, values) -> copy.put(id, List.copyOf(values))); + return Collections.unmodifiableMap(copy); + } + + public boolean contains(TagKey tag, Block block) { + return blocks.getOrDefault(tag.location(), List.of()).stream() + .anyMatch(holder -> holder.value() == block); + } + + public List> members(ResourceLocation id) { + return items.getOrDefault(id, List.of()); + } + + public Ingredient ingredient(ResourceLocation id) { + return Ingredient.of(members(id).stream().map(h -> new ItemStack(h.value()))); + } +} diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index 1d4e8032..ebfda5db 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -9,7 +9,7 @@ "screen.mmdlib.processor.paused": "Paused", "screen.mmdlib.processor.toggle": "{value}\n{action}", "screen.mmdlib.processor.action.unpause": "Click to unpause", - "screen.mmdlib.processor.action.stop": "Click to stop and clear reservations", + "screen.mmdlib.processor.action.stop": "Click to pause and release reservations\nProgress is kept", "screen.mmdlib.processor.action.pause": "Click to pause", "screen.mmdlib.processor.conflict.manual": "Redstone settings conflict:\nManual pause takes priority.", "screen.mmdlib.processor.conflict.redstone": "Redstone settings conflict:\nPaused by redstone.", diff --git a/src/main/resources/mmdlib/machine/base_powered_machine.xml b/src/main/resources/mmdlib/machine/base_powered_machine.xml new file mode 100644 index 00000000..11c067e8 --- /dev/null +++ b/src/main/resources/mmdlib/machine/base_powered_machine.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/src/main/resources/mmdlib/schema/screen.xsd b/src/main/resources/mmdlib/schema/screen.xsd index 5f46be5d..08c22069 100644 --- a/src/main/resources/mmdlib/schema/screen.xsd +++ b/src/main/resources/mmdlib/schema/screen.xsd @@ -219,6 +219,8 @@ + + diff --git a/src/main/resources/mmdlib/screen/base_powered_machine.xml b/src/main/resources/mmdlib/screen/base_powered_machine.xml index f560de4b..2b83f61e 100644 --- a/src/main/resources/mmdlib/screen/base_powered_machine.xml +++ b/src/main/resources/mmdlib/screen/base_powered_machine.xml @@ -4,7 +4,7 @@ - + diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/InventoryArchitectureSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/InventoryArchitectureSmoke.java index 86d840df..92004dab 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/InventoryArchitectureSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/InventoryArchitectureSmoke.java @@ -3,6 +3,8 @@ import java.util.*; import net.minecraft.core.*; +import net.minecraft.core.component.DataComponentPredicate; +import net.minecraft.core.component.DataComponents; import net.minecraft.nbt.*; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -13,6 +15,7 @@ import net.minecraft.world.level.block.entity.ChestBlockEntity; import net.minecraft.world.level.material.Fluids; +import net.neoforged.neoforge.common.crafting.DataComponentIngredient; import net.neoforged.neoforge.fluids.FluidStack; import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; import net.neoforged.neoforge.items.IItemHandler; @@ -110,6 +113,16 @@ static void fluidMigration(ServerLevel level) { original.addTank(2000, Role.INPUT); original.addTank(2000, Role.INPUT); original.tankId(0, "water").tankId(1, "lava"); + for (int invalid : new int[] {-1, 2}) { + try { + original.restrict(stack -> false, 0, invalid); + throw new AssertionError("Invalid tank index must fail"); + } catch (IndexOutOfBoundsException expected) { + require( + original.isFluidValid(0, new FluidStack(Fluids.WATER, 1)), + "Invalid restrictions must not partially change valid tanks"); + } + } original.restoreFluid(0, new FluidStack(Fluids.WATER, 1500)); original.restoreFluid(1, new FluidStack(Fluids.LAVA, 700)); var smaller = new FluidStorage(); @@ -214,8 +227,14 @@ static void bindings(ServerLevel level) { .items(left, storage) .items(right, storage) .items(items("result", IoMode.OUTPUT, 2), storage) - .recipeGroup("ingredients", "left", "right") - .build(); + .recipeGroup("ingredients", "left", "right"); + rejected(() -> resources.recipeGroup("left", "left", "result"), "left"); + rejected(() -> resources.recipeGroup("ingredients", "result"), "ingredients"); + rejected(() -> resources.items(items("ingredients", IoMode.INPUT, 0), storage), "ingredients"); + resources.build(); + require( + ((ItemGroup) resources.recipes().group("left").orElseThrow()).size() == 1, + "Rejected alias must preserve the original group"); var port = left.port(Direction.NORTH); require(port.getSlots() == 3, "group ports keep physical slot numbering"); require( @@ -275,12 +294,12 @@ public AutomationInventory screenAutomation() { "validation.xml", new java.io.ByteArrayInputStream( xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))); - if (variant == 0) + if (variant != 2) zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation.validate(screen, host); else rejected( () -> zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation.validate(screen, host), - variant == 1 ? "missing screen element 'input'" : "index 2"); + "index 2"); } } @@ -387,6 +406,41 @@ static void recipeFilters(ServerLevel level) { fluids.isFluidValid(0, new FluidStack(Fluids.LAVA, 1)) && !fluids.isFluidValid(0, new FluidStack(Fluids.WATER, 1)), "recipe replacement immediately updates fluid admission"); + int[] indexed = {0}; + var namedIngredient = + new DataComponentIngredient( + HolderSet.direct(Items.DIAMOND.builtInRegistryHolder()), + DataComponentPredicate.builder() + .expect(DataComponents.CUSTOM_NAME, Component.literal("Accepted")) + .build(), + false) { + @Override + public boolean isSimple() { + indexed[0]++; + return false; + } + }.toVanilla(); + changed.set( + changed.size() - 1, + new RecipeHolder<>( + ResourceLocation.parse("mmdlib_smoke:admission"), + new MachineRecipeEntry( + MachineRecipeTypeSmoke.GRINDING, + new MachineRecipe( + Map.of("input", List.of(new MachineIngredient.ItemPart(namedIngredient, 1))), + Map.of(), + 20)))); + manager.replaceRecipes(changed); + var named = new ItemStack(Items.DIAMOND); + for (int check = 0; check < 100; check++) { + named.set(DataComponents.CUSTOM_NAME, Component.literal("Accepted")); + require(storage.isItemValid(0, named), "Custom ingredient accepts matching components"); + named.remove(DataComponents.CUSTOM_NAME); + require( + !storage.isItemValid(0, named), + "Same-ID, same-count reload invalidates cache; custom predicates inspect each stack"); + } + require(indexed[0] == 1, "Repeated insertion probes reuse the ingredient index"); changed.removeIf(holder -> holder.value().getType() == MachineRecipeTypeSmoke.GRINDING); manager.replaceRecipes(changed); require( diff --git a/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java b/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java index 906407db..aee1d5ae 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java @@ -58,13 +58,23 @@ void showsCurrentPauseProcessingAndIdleStatesWithoutMachineSpecificBindings() th ResourceLocation.parse("mmdlib:icon_processor_play"), button.iconFor(ProcessorButtonState.of(false, false).wireValue()).orElseThrow()); assertEquals(ProcessorButtonState.PAUSED, ProcessorButtonState.of(true, true)); + for (var state : + new ProcessorButtonState[] { + ProcessorButtonState.STALLED, ProcessorButtonState.BLOCKED, ProcessorButtonState.HELD + }) + assertEquals( + ResourceLocation.parse("mmdlib:icon_processor_stop"), + button.iconFor(state.wireValue()).orElseThrow()); assertTrue(button.common().tooltip().isPresent()); } @Test void stateDecodingNeverTreatsAnUnknownSnapshotAsAManualPause() { assertTrue(ProcessorButtonState.fromWire(null).isEmpty()); - assertTrue(ProcessorButtonState.fromWire(java.math.BigInteger.valueOf(3)).isEmpty()); + assertTrue( + ProcessorButtonState.fromWire( + java.math.BigInteger.valueOf(ProcessorButtonState.values().length)) + .isEmpty()); for (var state : ProcessorButtonState.values()) assertEquals(state, ProcessorButtonState.fromWire(state.wireValue()).orElseThrow()); } diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java new file mode 100644 index 00000000..d1afd98a --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java @@ -0,0 +1,164 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; + +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.layout.ScreenLayout; +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.load.*; +import zone.moddev.mc.lib.gui.load.ScreenReader.Node; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.machine.definition.*; + +class ScreenFillGeneratorTest { + @TempDir Path directory; + + private void write(String name, String attrs, String body) throws Exception { + Files.createDirectories(directory.resolve("screen")); + Files.writeString( + directory.resolve("screen/" + name + ".xml"), + "" + body + ""); + } + + @Test + void defaultsOnlyRunWhenNoFillOverridesThemIncludingEmptyFills() throws Exception { + write( + "base", + "abstract=\"true\" block-entity=\"zone.moddev.mc.lib.gui.ScreenLoaderTest$Press\"", + ""); + write("default", "extends=\"test:base\" title=\"test.default\"", ""); + write( + "manual", + "extends=\"test:base\" title=\"test.manual\"", + ""); + write("empty", "extends=\"test:base\" title=\"test.empty\"", ""); + write( + "middle", + "abstract=\"true\" extends=\"test:base\"", + ""); + write("leaf", "extends=\"test:middle\" title=\"test.leaf\"", ""); + var calls = new ArrayList(); + var screens = + ScreenLoader.load( + List.of(new SourceRoot("test", directory, getClass().getClassLoader())), + Map.of(), + (id, node) -> { + calls.add(id + "=" + node.attributes().get("generator")); + return List.of( + new Node( + "slot", + Map.of("id", "generated", "index", "0"), + List.of(), + node.source(), + node.line())); + }); + assertEquals(List.of("test:default=test:default", "test:leaf=test:replacement"), calls); + assertTrue( + screens.get(ResourceLocation.parse("test:empty")).screen().find("generated").isEmpty()); + assertTrue( + screens.get(ResourceLocation.parse("test:manual")).screen().find("manual").isPresent()); + assertInstanceOf( + Element.Slot.class, + screens + .get(ResourceLocation.parse("test:default")) + .screen() + .find("generated") + .orElseThrow()); + } + + @Test + void realSimpleCrusherGeneratesSlotsAndInheritsCommonScreen() { + var sources = + List.of( + new SourceRoot( + "mmdlib", Path.of("src/main/resources/mmdlib"), getClass().getClassLoader()), + new SourceRoot( + "mmdlib_companion", + Path.of("src/companion/resources/mmdlib"), + getClass().getClassLoader())); + var machines = MachineDefinitions.load(sources); + var id = ResourceLocation.parse("mmdlib_companion:simple_crusher"); + // Only load library screens; the other companion screens bind companion-only classes. + var screens = + ScreenLoader.load( + List.of(sources.getFirst()), + Map.of(id, machines.get(id).screen()), + (key, node) -> MachineScreenGenerators.generate(machines.get(key), node)); + var screen = screens.get(id).screen(); + assertDoesNotThrow(() -> MachineScreenGenerators.validate(machines.get(id), screen)); + assertEquals( + List.of(0, 1, 2), + screen.elements().stream() + .filter(e -> e instanceof Element.Slot) + .map(e -> ((Element.Slot) e).index()) + .toList()); + assertTrue(screen.find("energy").isPresent()); + assertTrue(screen.find("info").isPresent()); + assertTrue(screen.find("generated_progress").isPresent()); + var progress = screen.find("generated_progress").orElseThrow(); + assertEquals(List.of(machines.get(id).recipeType().id()), progress.common().jeiRecipes()); + var layout = ScreenLayout.of(screen, TextMeasure.NONE); + var input = layout.bounds(screen.find("slot_0").orElseThrow()); + var output = layout.bounds(screen.find("slot_2").orElseThrow()); + var energy = layout.bounds(screen.find("energy").orElseThrow()); + // Center the entire processing cluster in the space before the fixed energy gauge. + assertEquals(7 + energy.x() - 6, input.x() + output.right(), 1); + assertEquals(energy.y() * 2 + energy.height(), input.y() * 2 + input.height(), 1); + assertEquals(input.y(), output.y()); + assertEquals( + input.y() * 2 + input.height(), + layout.bounds(progress).y() * 2 + layout.bounds(progress).height(), + 1); + var processorNodes = + MachineScreenGenerators.generate( + machines.get(id), + new Node( + "fill", + Map.of("name", "processor", "generator", "mmdlib:processors"), + List.of(), + "test.xml", + 1)); + assertEquals( + machines.get(id).recipeType().id().toString(), + processorNodes.getFirst().attributes().get("jei-recipes")); + var invalid = + ScreenLoader.load( + List.of(sources.getFirst()), + Map.of(id, machines.get(id).screen()), + (key, node) -> + List.of( + new Node( + "slot", Map.of("index", "99"), List.of(), node.source(), node.line()))); + assertThrows( + IllegalArgumentException.class, + () -> MachineScreenGenerators.validate(machines.get(id), invalid.get(id).screen())); + } + + @Test + void rejectsGeneratorWithLiteralChildren() throws Exception { + write( + "base", + "abstract=\"true\"", + ""); + write( + "child", + "extends=\"test:base\" title=\"test.child\"" + + " block-entity=\"zone.moddev.mc.lib.gui.ScreenLoaderTest$Press\"", + ""); + assertThrows( + IllegalArgumentException.class, + () -> + ScreenLoader.load( + List.of(new SourceRoot("test", directory, getClass().getClassLoader())))); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java new file mode 100644 index 00000000..09189863 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -0,0 +1,186 @@ +package zone.moddev.mc.lib.machine; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; + +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.machine.definition.MachineDefinitions; +import zone.moddev.mc.lib.machine.recipe.generation.GeneratorProperties; + +class MachineDefinitionsTest { + @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 void write(String text) throws Exception { + Files.createDirectories(directory.resolve("machine")); + Files.writeString(directory.resolve("machine/crusher.xml"), text); + } + + private String crusher() throws Exception { + return Files.readString(Path.of("src/companion/resources/mmdlib/machine/simple_crusher.xml")); + } + + @Test + void inheritsPowerAndExpandsStableGroupsAndCustomProperties() throws Exception { + write( + crusher() + .replace( + "", + "")); + var machine = MachineDefinitions.load(sources()).get(ResourceLocation.parse("test:crusher")); + assertEquals(200000, machine.power().capacity()); + assertEquals(1000, machine.power().input()); + assertEquals(0xd85b5b, machine.power().color()); + assertEquals( + List.of("input/0", "output/0", "output/1"), + machine.slots().stream().map(s -> s.id()).toList()); + assertEquals(List.of(1, 2), machine.groups().get(1).members()); + assertEquals( + Map.of("hardness-level-ratio", ".10"), machine.recipeType().generator().properties()); + assertFalse(machine.recipeType().generator().properties().containsKey("class")); + assertEquals("mmdlib:base_powered_machine", machine.screen().attributes().get("extends")); + } + + @Test + void growingCountKeepsExistingMemberIdentities() throws Exception { + write(crusher().replace("count=\"2\" role=\"output\"", "count=\"3\" role=\"output\"")); + var machine = MachineDefinitions.load(sources()).values().iterator().next(); + assertEquals( + List.of("input/0", "output/0", "output/1", "output/2"), + machine.slots().stream().map(s -> s.id()).toList()); + } + + @Test + void honorsGroupColorAndProcessorNameAndManualSlots() throws Exception { + write( + crusher() + .replace( + "", "")); + var machine = MachineDefinitions.load(sources()).values().iterator().next(); + assertEquals(0x123456, machine.power().color()); + assertEquals("Crushing", machine.processorName()); + assertEquals( + zone.moddev.mc.lib.api.capabilities.IoMode.DISABLED, machine.groups().getLast().mode()); + } + + @Test + void rejectsUnknownAndOverlappingAndUnsafeDefinitions() throws Exception { + write( + crusher() + .replace("capacity=", "capcity=") + .replace("", "")); + assertTrue( + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())) + .getMessage() + .contains("bogus")); + write( + crusher() + .replace( + " MachineDefinitions.load(sources())) + .getMessage() + .contains("Overlapping")); + write("]>" + crusher()); + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())); + } + + @Test + void rejectsReservedStorageKeysAtDefinitionLoad() throws Exception { + for (var key : + List.of( + "energy", + "automation", + "features", + "redstone_control", + "redstone_mode", + "id", + "components", + "mmdlib_features", + "mmdlib_machine")) { + write(crusher().replace("", "")); + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources()), key); + } + } + + @Test + void checksumIncludesInheritedSettingsButNotPathsOrFormatting() throws Exception { + var child = crusher().replace("mmdlib:base_powered_machine", "test:base"); + write(child); + var base = + Files.readString(Path.of("src/main/resources/mmdlib/machine/base_powered_machine.xml")); + Files.writeString(directory.resolve("machine/base.xml"), base); + var id = ResourceLocation.parse("test:crusher"); + var original = MachineDefinitions.load(sources()).get(id).checksum(); + write( + child + .replace("count=\"1\" role=\"input\"", "role=\"input\" count=\"1\"") + .replace("", "\n ")); + assertEquals(original, MachineDefinitions.load(sources()).get(id).checksum()); + var copied = directory.resolve("copy"); + Files.createDirectories(copied.resolve("machine")); + Files.writeString(copied.resolve("machine/base.xml"), base); + Files.writeString(copied.resolve("machine/crusher.xml"), child); + assertEquals( + original, + MachineDefinitions.load( + List.of(new SourceRoot("test", copied, getClass().getClassLoader()))) + .get(id) + .checksum()); + write(child); + Files.writeString( + directory.resolve("machine/base.xml"), + base.replace("capacity=\"100000\"", "capacity=\"200000\"")); + assertNotEquals(original, MachineDefinitions.load(sources()).get(id).checksum()); + Files.writeString(directory.resolve("machine/base.xml"), base); + for (var edited : + List.of( + child.replace("role=\"input\"", "role=\"input\" limit=\"16\""), + child.replace("role=\"output\"", "role=\"storage\""), + child.replace("id=\"crusher\"", "id=\"other\""), + child.replace("hardness-level-ratio=\".10\"", "hardness-level-ratio=\".20\""))) { + write(edited); + assertNotEquals(original, MachineDefinitions.load(sources()).get(id).checksum()); + } + } + + @Test + void rejectsCyclesBeforeProducingPlans() throws Exception { + write( + ""); + assertTrue( + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())) + .getMessage() + .contains("cycle")); + } + + @Test + void typedPropertiesReportSourceAndRejectTyposAndRanges() { + var p = new GeneratorProperties(Map.of("energy", "-1", "tiem", "100"), "crusher.xml", 17); + assertTrue( + assertThrows( + IllegalArgumentException.class, () -> p.requiredLong("energy", 1, Long.MAX_VALUE)) + .getMessage() + .contains("crusher.xml:17")); + assertThrows(IllegalArgumentException.class, () -> p.requireOnly("energy", "time")); + assertThrows(IllegalArgumentException.class, () -> p.requiredString("missing")); + } +} From fe7b53f91bb544a61ce8cc7643056db07d2aa355 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sun, 8 Nov 2026 04:08:49 +0200 Subject: [PATCH 2/3] Address XML machine review: inheritance, validation and shared metadata --- docs/xml-machines.md | 21 ++- .../mc/lib/gui/client/MachineScreen.java | 9 +- .../mc/lib/gui/load/ScreenFillGenerator.java | 16 +++ .../moddev/mc/lib/gui/load/ScreenLoader.java | 20 +-- .../moddev/mc/lib/gui/load/ScreenReader.java | 13 +- .../mc/lib/gui/load/ScreenTemplates.java | 34 +++-- .../lib/gui/model/ProcessorButtonState.java | 35 ++++- .../definition/MachineDefinitions.java | 123 ++++++++++++----- .../definition/MachineScreenGenerators.java | 24 +++- .../machine/inventory/MachineStorageKeys.java | 19 +++ .../neoforge/DefinedMachineEntity.java | 20 +-- .../lib/machine/neoforge/MachineContent.java | 11 +- .../neoforge/MachineInventoryLifecycle.java | 14 +- src/main/resources/mmdlib/schema/screen.xsd | 2 + .../mc/lib/gui/ProcessorButtonTest.java | 4 + .../mc/lib/gui/ScreenFillGeneratorTest.java | 56 ++++++++ .../lib/machine/MachineDefinitionsTest.java | 127 ++++++++++++++++++ 17 files changed, 429 insertions(+), 119 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/lib/gui/load/ScreenFillGenerator.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java diff --git a/docs/xml-machines.md b/docs/xml-machines.md index a07a937f..ded75012 100644 --- a/docs/xml-machines.md +++ b/docs/xml-machines.md @@ -86,10 +86,18 @@ provides: Machine and screen definitions have separate namespaces of IDs, so these two `mmdlib:base_powered_machine` resources serve different purposes. The machine inherits common configuration; its screen inherits the reusable visual template. -Abstract machines register nothing. Attributes and children merge through the -inheritance chain by element name and `id` (or `name` for fills). A child can -override just the energy capacity while retaining the base rates. Generator and -fill elements replace their inherited counterparts completely. +Abstract machines register nothing. Single-valued sections (`items`, `energy`, +`recipe-type`, `processor`, and the other machine sections) merge by element name, +so a child may change their IDs. Repeated slots/groups merge by element name and +`id`; fills merge by `name`. A child can override just the energy capacity while +retaining the base rates. Generator and fill elements replace their inherited +counterparts completely. Unnamed screen siblings remain in order: inherited +content first, followed by child content. Duplicated single-valued sections in +one file are errors, even if they have different IDs. + +Numbers use locale-independent syntax with a dot decimal separator, such as +`hardness="3.5"`. Invalid hardness values and resource locations report the source +file, line, attribute and value, including when authored in an inherited base. `` creates stable member IDs `input/0` and `input/1`, plus a recipe/automation group named `input`. Growing the count @@ -157,7 +165,10 @@ A machine can replace that region: This screen inherits `extends` from the machine base. To replace the generator instead, use ``. An explicit fill always replaces the template's default, including an empty fill. The nearest -fill in the inheritance chain wins. A generator cannot have literal children, +fill in the inheritance chain wins. Extra attributes are allowed only when a +generator is present, and that generator validates its own options. Literal fills +and placeholders reject unknown attributes even when later overridden. +A generator cannot have literal children, and required placeholders cannot also have defaults. Custom screen templates can use these fragment generators on their placeholders: 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 31eaec07..9d5cb3c5 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 @@ -765,14 +765,7 @@ public List tooltipLines(Element e, int mx, int my) { if (e instanceof Element.Button button && button.processorControl()) { var action = ProcessorButtonState.fromWire(menu.value(button, false)) - .map( - state -> - switch (state) { - case PAUSED -> "screen.mmdlib.processor.action.unpause"; - case PROCESSING, STALLED, BLOCKED, HELD -> - "screen.mmdlib.processor.action.stop"; - case IDLE -> "screen.mmdlib.processor.action.pause"; - }) + .map(ProcessorButtonState::action) .map(key -> Component.translatable(key).getString()) .orElse(""); translated = translated.replace("{action}", action); diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenFillGenerator.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenFillGenerator.java new file mode 100644 index 00000000..eface244 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenFillGenerator.java @@ -0,0 +1,16 @@ +package zone.moddev.mc.lib.gui.load; + +import java.util.List; + +import net.minecraft.resources.ResourceLocation; + +/** Supplies ordinary screen elements for a generated fill or placeholder. */ +@FunctionalInterface +public interface ScreenFillGenerator { + ScreenFillGenerator UNAVAILABLE = + (id, node) -> { + throw new ScreenException(node.source(), node.line(), "No generator context for " + id); + }; + + List generate(ResourceLocation screen, ScreenReader.Node template); +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java index fde8b3cd..ad92bcb7 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java @@ -35,20 +35,14 @@ private ScreenLoader() {} * @throws IllegalArgumentException if two files have the same ID */ public static Map load(List sources) { - return load( - sources, - Map.of(), - (id, node) -> { - throw new ScreenException(node.source(), node.line(), "No generator context for " + id); - }); + return load(sources, Map.of(), ScreenFillGenerator.UNAVAILABLE); } /** Loads embedded machine screens through the same composition and validation pipeline. */ public static Map load( List sources, Map embedded, - java.util.function.BiFunction> - generator) { + ScreenFillGenerator generator) { WidgetTypes.GLOBAL.freeze(); var discovered = discover(sources); var screens = new TreeMap(); @@ -97,20 +91,14 @@ private static Definitions discover(List sources) { private static Map read( Map definitions, WidgetTypes widgets) { - return read( - definitions, - widgets, - (id, node) -> { - throw new ScreenException(node.source(), node.line(), "No generator context for " + id); - }); + return read(definitions, widgets, ScreenFillGenerator.UNAVAILABLE); } /** Compiles definition trees, useful for generators and definition validation tools. */ public static Map read( Map definitions, WidgetTypes widgets, - java.util.function.BiFunction> - generator) { + ScreenFillGenerator generator) { var screens = new TreeMap(); var templates = new ScreenTemplates(definitions, generator); // Discover all definitions first: bases may be in later files or another mod. diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java index 775d7392..2629552c 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java @@ -31,6 +31,7 @@ import zone.moddev.mc.lib.gui.model.Common; import zone.moddev.mc.lib.gui.model.Element; import zone.moddev.mc.lib.gui.model.Element.*; +import zone.moddev.mc.lib.gui.model.ProcessorButtonState; import zone.moddev.mc.lib.gui.model.Screen; import zone.moddev.mc.lib.gui.model.Text; import zone.moddev.mc.lib.gui.model.ValueRef; @@ -433,14 +434,14 @@ private Node processorDefaults(Node node) { var attributes = new LinkedHashMap<>(node.attributes()); attributes.put( "icons", - "mmdlib:icon_processor_pause mmdlib:icon_processor_stop mmdlib:icon_processor_play" - + " mmdlib:icon_processor_stop mmdlib:icon_processor_stop" - + " mmdlib:icon_processor_stop"); + java.util.Arrays.stream(ProcessorButtonState.values()) + .map(state -> state.icon().toString()) + .collect(java.util.stream.Collectors.joining(" "))); attributes.put( "value-labels", - "screen.mmdlib.processor.paused screen.mmdlib.processor.processing" - + " screen.mmdlib.processor.idle screen.mmdlib.machine.stalled" - + " screen.mmdlib.machine.blocked screen.mmdlib.machine.held"); + java.util.Arrays.stream(ProcessorButtonState.values()) + .map(ProcessorButtonState::label) + .collect(java.util.stream.Collectors.joining(" "))); if (!attributes.containsKey("tooltip")) attributes.put("tooltip", "screen.mmdlib.processor.toggle"); return new Node(node.name(), attributes, node.children(), node.source(), node.line()); diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java index 6ea7af99..652b67cb 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenTemplates.java @@ -15,21 +15,32 @@ final class ScreenTemplates { private final Map definitions; private final Map resolved = new LinkedHashMap<>(); private final LinkedHashSet visiting = new LinkedHashSet<>(); - private final java.util.function.BiFunction> generator; + private final ScreenFillGenerator generator; ScreenTemplates(Map definitions) { - this( - definitions, - (id, node) -> { - throw error(node, "No generator context for " + id); - }); + this(definitions, ScreenFillGenerator.UNAVAILABLE); } - ScreenTemplates( - Map definitions, - java.util.function.BiFunction> generator) { + ScreenTemplates(Map definitions, ScreenFillGenerator generator) { this.definitions = Map.copyOf(definitions); this.generator = generator; + definitions.values().forEach(ScreenTemplates::validateAttributes); + } + + private static void validateAttributes(Node node) { + if (node.name().equals("fill") || node.name().equals("placeholder")) { + boolean generated = node.attributes().containsKey("generator"); + if (generated && ResourceLocation.tryParse(node.attributes().get("generator")) == null) + throw error(node, "Invalid generator='" + node.attributes().get("generator") + "'"); + for (var key : node.attributes().keySet()) + if (!key.equals("name") + && !key.equals("generator") + && !(node.name().equals("placeholder") && key.equals("required")) + && !generated) + throw error( + node, "Unknown attribute " + key + " on <" + node.name() + "> without a generator"); + } + node.children().forEach(ScreenTemplates::validateAttributes); } static boolean flag(Node node, String attribute) { @@ -46,7 +57,8 @@ Node resolve(ResourceLocation id) { var parent = root.attributes().get("extends"); Node result = root; if (parent != null) { - var parentId = ResourceLocation.parse(parent); + var parentId = ResourceLocation.tryParse(parent); + if (parentId == null) throw error(root, "Invalid extends='" + parent + "'"); if (!definitions.containsKey(parentId)) throw error(root, "Unknown base screen: " + parent); var base = resolve(parentId); var placeholders = placeholders(base); @@ -135,7 +147,7 @@ private List expand(ResourceLocation id, List nodes) { if (flag(node, "required")) throw error(node, "Missing required fill: " + node.attributes().get("name")); if (node.attributes().containsKey("generator")) { - var generated = generator.apply(id, node); + var generated = generator.generate(id, node); for (var child : generated) rejectTemplates(child); result.addAll(generated); } else result.addAll(expand(id, node.children())); diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java b/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java index 765d8bf5..77320b64 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/ProcessorButtonState.java @@ -3,14 +3,37 @@ import java.math.BigInteger; import java.util.Optional; +import net.minecraft.resources.ResourceLocation; + /** Current processor activity shown by a generic button, independently of its click action. */ public enum ProcessorButtonState { - PAUSED, - PROCESSING, - IDLE, - STALLED, - BLOCKED, - HELD; + PAUSED("pause", "screen.mmdlib.processor.paused", "unpause"), + PROCESSING("stop", "screen.mmdlib.processor.processing", "stop"), + IDLE("play", "screen.mmdlib.processor.idle", "pause"), + STALLED("stop", "screen.mmdlib.machine.stalled", "stop"), + BLOCKED("stop", "screen.mmdlib.machine.blocked", "stop"), + HELD("stop", "screen.mmdlib.machine.held", "stop"); + + private final ResourceLocation icon; + private final String label, action; + + ProcessorButtonState(String icon, String label, String action) { + this.icon = ResourceLocation.parse("mmdlib:icon_processor_" + icon); + this.label = label; + this.action = "screen.mmdlib.processor.action." + action; + } + + public ResourceLocation icon() { + return icon; + } + + public String label() { + return label; + } + + public String action() { + return action; + } public static ProcessorButtonState of( zone.moddev.mc.lib.machine.feature.RecipeProcessor processor) { 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 af1da325..099e1f7c 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 @@ -14,6 +14,7 @@ import zone.moddev.mc.lib.gui.load.ScreenReader; import zone.moddev.mc.lib.gui.load.ScreenReader.Node; import zone.moddev.mc.lib.machine.definition.MachineDefinition.*; +import zone.moddev.mc.lib.machine.inventory.MachineStorageKeys; /** * Discovers machine XML, composes inheritance, then validates concrete plans before registration. @@ -69,34 +70,49 @@ private static Node resolve( if (node == null) throw new IllegalArgumentException("Unknown base machine " + id); if (!visiting.add(id)) throw error(node, "Machine inheritance cycle " + visiting); var parent = node.attributes().get("extends"); - var result = - parent == null - ? node - : merge(resolve(ResourceLocation.parse(parent), nodes, resolved, visiting), node); + var result = node; + if (parent != null) { + var parentId = resource(node, "extends"); + if (!nodes.containsKey(parentId)) throw error(node, "Unknown base machine " + parent); + result = merge(resolve(parentId, nodes, resolved, visiting), node); + } visiting.remove(id); resolved.put(id, result); return result; } - private static String key(Node node) { - return node.name() - + ":" - + node.attributes().getOrDefault("id", node.attributes().getOrDefault("name", "")); + private static String key(Node parent, Node node) { + // These sections contain singletons; their ID is configuration, not merge identity. + if (Set.of("machine", "storage", "recipe-type").contains(parent.name())) return node.name(); + var id = node.attributes().getOrDefault("id", node.attributes().get("name")); + // Ordinary unnamed screen siblings are ordered content, never implicit overrides. + return id == null ? null : node.name() + ":" + id; } 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()); attrs.putAll(child.attributes()); - var children = new LinkedHashMap(); - base.children().forEach(n -> children.put(key(n), n)); - for (var n : child.children()) children.merge(key(n), n, MachineDefinitions::merge); + var children = new ArrayList<>(base.children()); + var indexes = new HashMap(); + for (int i = 0; i < children.size(); i++) { + var key = key(base, children.get(i)); + if (key != null && indexes.putIfAbsent(key, i) != null) + throw error(children.get(i), "Duplicate definition " + key); + } + var seen = new HashSet(); + for (var node : child.children()) { + var key = key(child, node); + if (key != null && !seen.add(key)) throw error(node, "Duplicate definition " + key); + var index = key == null ? null : indexes.get(key); + if (index != null) children.set(index, merge(children.get(index), node)); + else { + if (key != null) indexes.put(key, children.size()); + children.add(node); + } + } return new Node( - child.name(), - Map.copyOf(attrs), - List.copyOf(children.values()), - child.source(), - child.line()); + child.name(), Map.copyOf(attrs), List.copyOf(children), child.source(), child.line()); } private static final Map> ATTRIBUTES = @@ -145,11 +161,25 @@ private static void validateTree(Node node) { for (var attr : node.attributes().keySet()) if (!allowed.contains(attr)) throw error(node, "Unknown attribute " + attr); } + // Validate values where they were authored, before inherited attributes acquire a child source. + var resources = + switch (node.name()) { + case "machine" -> List.of("extends"); + case "block" -> List.of("mineable"); + case "appearance" -> List.of("front", "side", "top", "bottom"); + case "recipe-type" -> List.of("id"); + case "processor" -> List.of("recipe-type"); + default -> List.of(); + }; + for (var attribute : resources) + if (node.attributes().containsKey(attribute)) resource(node, attribute); + if (node.name().equals("block") && node.attributes().containsKey("hardness")) hardness(node); var seen = new HashSet(); for (var child : node.children()) { if (!CHILDREN.getOrDefault(node.name(), Set.of()).contains(child.name())) throw error(child, "Unexpected child of " + node.name()); - if (!seen.add(key(child))) throw error(child, "Duplicate definition " + key(child)); + var key = key(node, child); + if (key != null && !seen.add(key)) throw error(child, "Duplicate definition " + key); validateTree(child); } } @@ -165,25 +195,17 @@ private static MachineDefinition plan( requireChoice(appearance, "preset", "cube-front"); var textures = new HashMap(); for (var face : List.of("front", "side", "top")) - textures.put(face, ResourceLocation.parse(required(appearance, face)).toString()); + textures.put(face, resource(appearance, face).toString()); textures.put( "bottom", - ResourceLocation.parse(appearance.attributes().getOrDefault("bottom", textures.get("side"))) - .toString()); + appearance.attributes().containsKey("bottom") + ? resource(appearance, "bottom").toString() + : textures.get("side")); var storage = one(root, "storage"); var power = one(storage, "energy"); var items = one(storage, "items"); - if (Set.of( - "energy", - "features", - "automation", - "redstone_control", - "redstone_mode", - "id", - "components", - "mmdlib_features", - "mmdlib_machine") - .contains(required(items, "id"))) + if (required(items, "id").equals("energy") + || MachineStorageKeys.RESERVED.contains(required(items, "id"))) throw error(items, "Reserved storage ID " + required(items, "id")); String powerId = required(power, "id"); // BasePoweredMachine owns this stable save key. @@ -286,7 +308,7 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 generatorNode.line(), loaders.get(generatorNode.source())); } - var recipeId = ResourceLocation.parse(required(recipe, "id")); + var recipeId = resource(recipe, "id"); var recipeType = new RecipeType( recipeId, @@ -296,7 +318,7 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 if (!recipeType.declare() && generator != null) throw error(recipe, "Only a declaring recipe type may supply a generator"); var processor = one(root, "processor"); - if (!recipeId.equals(ResourceLocation.parse(required(processor, "recipe-type")))) + if (!recipeId.equals(resource(processor, "recipe-type"))) throw error(processor, "Unknown recipe type"); // All item input groups are linked automatically; an explicit list must agree. var inputs = @@ -314,14 +336,13 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 attrs.put("title", "block." + id.getNamespace() + "." + id.getPath().replace('/', '.')); attrs.put("title-fallback", required(root, "name")); attrs.put("block-entity", "zone.moddev.mc.lib.machine.neoforge.DefinedMachineEntity"); - float hardness = Float.parseFloat(required(block, "hardness")); - if (!Float.isFinite(hardness) || hardness < 0) throw error(block, "Invalid hardness"); + float hardness = hardness(block); return new MachineDefinition( id, checksum(root), required(root, "name"), hardness, - ResourceLocation.parse(required(block, "mineable")), + resource(block, "mineable"), bool(block, "requires-tool", false), textures, new Power( @@ -346,6 +367,36 @@ public static String required(Node node, String key) { return value; } + public static ResourceLocation resource(Node node, String key) { + var value = required(node, key); + var id = ResourceLocation.tryParse(value); + if (id == null) + throw error( + node, + "Invalid " + + key + + "='" + + value + + "': expected a resource location such as minecraft:stone"); + return id; + } + + private static float hardness(Node node) { + var value = required(node, "hardness"); + try { + float parsed = Float.parseFloat(value); + if (Float.isFinite(parsed) && parsed >= 0) return parsed; + } catch (NumberFormatException ignored) { + // Report a definition error with the original file, line, attribute and value. + } + throw error( + node, + "Invalid hardness='" + + value + + "': expected a finite nonnegative number; use '.' as the decimal separator (for" + + " example 3.5)"); + } + /** CRC of the resolved XML tree: includes inherited settings, excludes paths/formatting. */ private static String checksum(Node root) { var text = new StringBuilder(); 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 24513ab9..47181dd7 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 @@ -39,7 +39,7 @@ public static synchronized void register(ResourceLocation id, Generator generato public static List generate(MachineDefinition machine, Node node) { if (machine == null) throw MachineDefinitions.error(node, "Machine fill generator requires a machine definition"); - var id = ResourceLocation.parse(node.attributes().get("generator")); + var id = MachineDefinitions.resource(node, "generator"); var generator = GENERATORS.get(id); if (generator == null) throw MachineDefinitions.error(node, "Unknown fill generator " + id); return List.copyOf(generator.generate(machine, node)); @@ -65,6 +65,28 @@ public static void validate(MachineDefinition machine, Screen screen) { } } + /** Validates and computes immutable highlight bindings once at definition load. */ + public static Map> compileHighlights( + MachineDefinition machine, Screen screen) { + validate(machine, screen); + var highlights = new LinkedHashMap>(); + var slots = new HashMap(); + for (var group : machine.groups()) { + highlights.put(group.id(), new ArrayList<>()); + group.members().forEach(index -> slots.put(index, group.id())); + } + highlights.put(machine.power().id(), new ArrayList<>()); + for (var element : screen.elements()) { + var group = + element instanceof Element.Slot slot + ? slots.get(slot.index()) + : element instanceof Element.EnergyGauge ? machine.power().id() : null; + if (group != null) element.common().id().ifPresent(id -> highlights.get(group).add(id)); + } + highlights.replaceAll((key, ids) -> List.copyOf(ids)); + return Map.copyOf(highlights); + } + private static void options(Node node, Set allowed) { for (var key : node.attributes().keySet()) if (!Set.of("name", "generator").contains(key) && !allowed.contains(key)) 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 new file mode 100644 index 00000000..2ec7e7fc --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/inventory/MachineStorageKeys.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.lib.machine.inventory; + +import java.util.Set; + +/** 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"); + + private MachineStorageKeys() {} +} 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 7c916abc..5bb4c5ad 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 @@ -13,9 +13,6 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.api.redstone.RedstoneControl; -import zone.moddev.mc.lib.gui.Screens; -import zone.moddev.mc.lib.gui.model.Element; -import zone.moddev.mc.lib.gui.runtime.ScreenInventoryValidation; import zone.moddev.mc.lib.machine.definition.MachineDefinition; import zone.moddev.mc.lib.machine.feature.RecipeProcessor; import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; @@ -48,13 +45,8 @@ public DefinedMachineEntity( items.slotId(i, slot.id()).role(slot.role(), i).limit(slot.limit(), i); } resources = new MachineResources(this, this::facing); - var screen = Screens.get(definition.id()).orElseThrow().screen(); + var highlights = MachineContent.screenHighlights(definition.id()); for (var group : definition.groups()) { - var highlights = - screen.elements().stream() - .filter(e -> e instanceof Element.Slot s && group.members().contains(s.index())) - .flatMap(e -> e.common().id().stream()) - .toList(); resources.items( new ResourceGroup<>( group.id(), @@ -63,15 +55,10 @@ public DefinedMachineEntity( ResourceType.ITEMS, group.mode(), group.members(), - highlights, + highlights.get(group.id()), new SideConfig(group.mode())), items); } - var energyHighlights = - screen.elements().stream() - .filter(e -> e instanceof Element.EnergyGauge) - .flatMap(e -> e.common().id().stream()) - .toList(); resources.energy( new ResourceGroup<>( "energy", @@ -80,7 +67,7 @@ public DefinedMachineEntity( ResourceType.ENERGY, IoMode.INPUT, List.of(0), - energyHighlights, + highlights.get(definition.power().id()), new SideConfig(IoMode.INPUT)), energy); for (var group : definition.groups()) @@ -94,7 +81,6 @@ public DefinedMachineEntity( Component.literal(definition.processorName()))); inventory = poweredInventory(resources.automation()).items(definition.itemStorageId(), items); redstone = definition.redstone() ? new RedstoneControl(this, List.of(features)) : null; - ScreenInventoryValidation.validate(this); } @Override 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 b43857d8..d6c04e31 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 @@ -27,6 +27,7 @@ public final class MachineContent { public static final ResourceLocation RECOVERY = ResourceLocation.parse("mmdlib:machine_recovery_capsule"); private static Map definitions = Map.of(); + private static Map>> screenHighlights = Map.of(); private static final Map recipes = new TreeMap<>(); private static final Map blocks = new TreeMap<>(); private static final Map> entities = @@ -45,6 +46,10 @@ public static Map definitions() { return definitions; } + static Map> screenHighlights(ResourceLocation id) { + return Objects.requireNonNull(screenHighlights.get(id), "No compiled screen for " + id); + } + public static MachineRecipeType recipeType(ResourceLocation id) { var type = recipes.get(id); if (type == null) throw new IllegalArgumentException("Unknown machine recipe type " + id); @@ -81,8 +86,12 @@ public static void load(List roots) { roots, embedded, (id, node) -> MachineScreenGenerators.generate(definitions.get(id), node)); + var highlights = new TreeMap>>(); definitions.forEach( - (id, machine) -> MachineScreenGenerators.validate(machine, screens.get(id).screen())); + (id, machine) -> + highlights.put( + id, MachineScreenGenerators.compileHighlights(machine, screens.get(id).screen()))); + screenHighlights = Map.copyOf(highlights); Screens.install(screens); } 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 6d88c707..0a6edc75 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 @@ -13,23 +13,13 @@ import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.machine.feature.FeatureHost; +import zone.moddev.mc.lib.machine.inventory.MachineStorageKeys; /** * Composes storage persistence, processor restoration, transfer policy and displaced-content * recovery. */ public final class MachineInventoryLifecycle { - private static final Set RESERVED_KEYS = - Set.of( - "automation", - "features", - "redstone_control", - "redstone_mode", - "id", - "components", - "mmdlib_features", - "mmdlib_machine"); - public enum PausePolicy { PROCESSING_ONLY(true, false), TRANSFERS_ONLY(false, true), @@ -93,7 +83,7 @@ private MachineInventoryLifecycle add(String key, Object storage) { Objects.requireNonNull(storage); if (key == null || key.isBlank() - || RESERVED_KEYS.contains(key) + || MachineStorageKeys.RESERVED.contains(key) || storages.containsKey(key) || storages.containsValue(storage)) throw new IllegalArgumentException("Duplicate or reserved storage key: " + key); diff --git a/src/main/resources/mmdlib/schema/screen.xsd b/src/main/resources/mmdlib/schema/screen.xsd index 08c22069..8516c63a 100644 --- a/src/main/resources/mmdlib/schema/screen.xsd +++ b/src/main/resources/mmdlib/schema/screen.xsd @@ -220,6 +220,8 @@ + diff --git a/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java b/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java index aee1d5ae..f8fa9421 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ProcessorButtonTest.java @@ -58,6 +58,10 @@ void showsCurrentPauseProcessingAndIdleStatesWithoutMachineSpecificBindings() th ResourceLocation.parse("mmdlib:icon_processor_play"), button.iconFor(ProcessorButtonState.of(false, false).wireValue()).orElseThrow()); assertEquals(ProcessorButtonState.PAUSED, ProcessorButtonState.of(true, true)); + for (var state : ProcessorButtonState.values()) { + assertEquals(state.icon(), button.iconFor(state.wireValue()).orElseThrow()); + assertEquals(state.label(), button.labelFor(state.wireValue()).orElseThrow().key()); + } for (var state : new ProcessorButtonState[] { ProcessorButtonState.STALLED, ProcessorButtonState.BLOCKED, ProcessorButtonState.HELD diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java index d1afd98a..f86630b0 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java @@ -95,6 +95,12 @@ void realSimpleCrusherGeneratesSlotsAndInheritsCommonScreen() { (key, node) -> MachineScreenGenerators.generate(machines.get(key), node)); var screen = screens.get(id).screen(); assertDoesNotThrow(() -> MachineScreenGenerators.validate(machines.get(id), screen)); + var highlights = MachineScreenGenerators.compileHighlights(machines.get(id), screen); + assertEquals(List.of("slot_0"), highlights.get("input")); + assertEquals(List.of("slot_1", "slot_2"), highlights.get("output")); + assertEquals(List.of("energy"), highlights.get("energy")); + assertThrows(UnsupportedOperationException.class, () -> highlights.put("other", List.of())); + assertThrows(UnsupportedOperationException.class, () -> highlights.get("input").add("other")); assertEquals( List.of(0, 1, 2), screen.elements().stream() @@ -143,6 +149,56 @@ void realSimpleCrusherGeneratesSlotsAndInheritsCommonScreen() { () -> MachineScreenGenerators.validate(machines.get(id), invalid.get(id).screen())); } + @Test + void literalTemplateAttributesRejectTyposBeforeExpansion() throws Exception { + var sources = List.of(new SourceRoot("test", directory, getClass().getClassLoader())); + for (var attribute : List.of("colums=\"2\"", "generatr=\"test:fill\"")) { + write( + "base", + "abstract=\"true\" block-entity=\"zone.moddev.mc.lib.gui.ScreenLoaderTest$Press\"", + ""); + write("child", "extends=\"test:base\" title=\"test.child\"", ""); + assertTrue( + assertThrows(ScreenException.class, () -> ScreenLoader.load(sources)) + .getMessage() + .contains("Unknown attribute")); + write( + "base", + "abstract=\"true\" block-entity=\"zone.moddev.mc.lib.gui.ScreenLoaderTest$Press\"", + ""); + write( + "child", + "extends=\"test:base\" title=\"test.child\"", + ""); + assertTrue( + assertThrows(ScreenException.class, () -> ScreenLoader.load(sources)) + .getMessage() + .contains("Unknown attribute")); + } + var invalid = + new Node( + "screen", + Map.of("title", "test", "block-entity", "test.Host"), + List.of( + new Node( + "placeholder", + Map.of("name", "body", "colums", "2"), + List.of(), + "machine.xml", + 7)), + "machine.xml", + 1); + var error = + assertThrows( + ScreenException.class, + () -> + ScreenLoader.read( + Map.of(ResourceLocation.parse("test:embedded"), invalid), + new WidgetTypes(), + ScreenFillGenerator.UNAVAILABLE)); + assertTrue(error.getMessage().contains("machine.xml:7: Unknown attribute colums")); + } + @Test void rejectsGeneratorWithLiteralChildren() throws Exception { write( 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 09189863..437cd58f 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -161,6 +161,133 @@ void checksumIncludesInheritedSettingsButNotPathsOrFormatting() throws Exception } } + @Test + void singletonOverridesCanChangeTheirIds() throws Exception { + write( + """ + + + + + + """); + Files.writeString( + directory.resolve("machine/base.xml"), + crusher().replace("", + "")); + assertTrue( + assertThrows(IllegalArgumentException.class, () -> MachineDefinitions.load(sources())) + .getMessage() + .contains("Duplicate definition processor")); + } + + @Test + void inheritedScreensPreserveEveryUnnamedSiblingInOrder() throws Exception { + write( + crusher() + .replace("mmdlib:base_powered_machine", "test:base") + .replace( + "", + "")); + var base = + Files.readString(Path.of("src/main/resources/mmdlib/machine/base_powered_machine.xml")) + .replace( + "extends=\"mmdlib:base_powered_machine\"/>", + ">"); + Files.writeString(directory.resolve("machine/base.xml"), base); + var plan = MachineDefinitions.load(sources()).get(ResourceLocation.parse("test:crusher")); + var children = plan.screen().children(); + assertEquals(4, children.size()); + assertEquals( + List.of("first", "second", "base_end", "third", "fourth", "fifth"), + children.stream() + .flatMap(n -> n.children().stream()) + .map(n -> n.attributes().get("text")) + .toList()); + var built = + zone.moddev.mc.lib.gui.load.ScreenLoader.read( + Map.of(plan.id(), plan.screen()), + new zone.moddev.mc.lib.gui.WidgetTypes(), + zone.moddev.mc.lib.gui.load.ScreenFillGenerator.UNAVAILABLE); + assertEquals( + 6, + built.get(plan.id()).elements().stream() + .filter(e -> e instanceof zone.moddev.mc.lib.gui.model.Element.Label) + .count()); + } + + @Test + void malformedValuesNameTheirSourceAttributeAndValue() throws Exception { + var edits = new LinkedHashMap(); + edits.put( + "extends='Invalid:base'", crusher().replace("mmdlib:base_powered_machine", "Invalid:base")); + edits.put( + "front='Invalid:front'", + crusher().replace("minecraft:block/furnace_front", "Invalid:front")); + edits.put( + "bottom='Invalid:bottom'", + crusher() + .replace("preset=\"cube-front\"", "preset=\"cube-front\" bottom=\"Invalid:bottom\"")); + edits.put( + "mineable='Invalid:tool'", + crusher().replace("", "")); + edits.put( + "id='Invalid:type'", + crusher() + .replace( + "", "")); + for (var edit : edits.entrySet()) { + write(edit.getValue()); + var message = + assertThrows( + zone.moddev.mc.lib.gui.load.ScreenException.class, + () -> MachineDefinitions.load(sources())) + .getMessage(); + assertTrue(message.matches("(?s).*crusher\\.xml:[1-9][0-9]*:.*"), message); + assertTrue(message.contains(edit.getKey()), message); + if (edit.getKey().contains("3,5")) assertTrue(message.contains("3.5"), message); + } + write( + crusher() + .replace("mmdlib:base_powered_machine", "test:base") + .replace("", "")); + Files.writeString( + directory.resolve("machine/base.xml"), + Files.readString(Path.of("src/main/resources/mmdlib/machine/base_powered_machine.xml")) + .replace("hardness=\"3.5\"", "hardness=\"3,5\"")); + var inheritedError = + assertThrows( + zone.moddev.mc.lib.gui.load.ScreenException.class, + () -> MachineDefinitions.load(sources())) + .getMessage(); + assertTrue( + inheritedError.matches("(?s).*base\\.xml:[1-9][0-9]*:.*hardness='3,5'.*"), inheritedError); + } + @Test void rejectsCyclesBeforeProducingPlans() throws Exception { write( From 82cb1a288f0c52c6138bd924516b4829e05141ef Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Sun, 8 Nov 2026 05:18:38 +0200 Subject: [PATCH 3/3] Add XML lava furnace, fluid recipe adapters and tank tooltips --- docs/gui-screens.md | 4 + docs/xml-machines.md | 133 ++++++- scripts/companion.gradle | 21 + .../companion/recipe/LavaSmeltingRecipes.java | 26 ++ .../recipe/simple_lava_furnace.json | 10 + .../mmdlib/machine/simple_lava_furnace.xml | 18 + .../test/SimpleCrusherClientSmoke.java | 133 +++++-- .../test/SimpleLavaFurnaceTests.java | 358 ++++++++++++++++++ .../api/automation/AutomationInventory.java | 4 +- .../api/automation/AutomationTransfers.java | 6 +- .../mc/lib/api/capabilities/FluidStorage.java | 1 + .../mc/lib/gui/client/MachineScreen.java | 1 + .../moddev/mc/lib/gui/load/ScreenBinder.java | 20 + .../moddev/mc/lib/gui/model/ValueRef.java | 21 +- .../mc/lib/gui/runtime/MachineMenu.java | 23 ++ .../moddev/mc/lib/gui/runtime/ScreenHost.java | 7 + .../machine/definition/MachineDefinition.java | 10 + .../definition/MachineDefinitions.java | 116 +++++- .../definition/MachineScreenGenerators.java | 59 ++- .../mc/lib/machine/neoforge/BaseMachine.java | 285 ++++++++++++++ .../machine/neoforge/BasePoweredMachine.java | 261 +------------ .../machine/neoforge/DefinedMachineBlock.java | 10 + .../neoforge/DefinedMachineEntity.java | 138 ++++++- .../machine/neoforge/FluidTankTooltip.java | 73 ++++ .../neoforge/GeneratedMachineRecipes.java | 51 ++- .../lib/machine/neoforge/MachineContent.java | 35 +- .../neoforge/MachineInventoryLifecycle.java | 6 +- .../machine/neoforge/MachineRecoveryItem.java | 17 +- .../generation/GeneratorProperties.java | 7 + .../generation/OreToIngotGenerator.java | 2 +- .../generation/RecipeGenerationContext.java | 4 + .../recipe/generation/RecipeTransforms.java | 44 +++ .../generation/SmeltingRecipeGenerator.java | 50 +++ .../resources/assets/mmdlib/lang/en_us.json | 6 + .../resources/mmdlib/machine/base_machine.xml | 9 + .../mmdlib/machine/base_powered_machine.xml | 7 +- src/main/resources/mmdlib/schema/screen.xsd | 2 +- .../resources/mmdlib/screen/base_machine.xml | 22 ++ .../mmdlib/screen/base_powered_machine.xml | 22 +- .../mc/lib/gui/ScreenFillGeneratorTest.java | 45 ++- .../moddev/mc/lib/gui/ScreenLoaderTest.java | 4 +- .../lib/machine/MachineDefinitionsTest.java | 45 ++- 42 files changed, 1716 insertions(+), 400 deletions(-) create mode 100644 src/companion/java/zone/moddev/mc/companion/recipe/LavaSmeltingRecipes.java create mode 100644 src/companion/resources/data/mmdlib_companion/recipe/simple_lava_furnace.json create mode 100644 src/companion/resources/mmdlib/machine/simple_lava_furnace.xml create mode 100644 src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidTankTooltip.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeTransforms.java create mode 100644 src/main/java/zone/moddev/mc/lib/machine/recipe/generation/SmeltingRecipeGenerator.java create mode 100644 src/main/resources/mmdlib/machine/base_machine.xml create mode 100644 src/main/resources/mmdlib/screen/base_machine.xml diff --git a/docs/gui-screens.md b/docs/gui-screens.md index ffcf02fd..b89b5394 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -735,6 +735,7 @@ the block at a position), and that instance is what the getters are called on: | Form | Resolves to | Called as | |---|---|---| | `#method` | A public instance method on the screen's `block-entity` class (or a superclass or interface of it), taking no parameters | `blockEntity.method()` | +| `#method(index)` | A public instance getter taking one fixed nonnegative `int`, e.g. `#getTankFluid(0)` | `blockEntity.method(index)` | | `Class#method` | A public **static** method taking the block entity (or a supertype) as its only parameter, e.g. a helper shared by several machines | `Class.method(blockEntity)` | Resolution happens in two steps. At load, each reference is looked up from the class and name @@ -743,6 +744,9 @@ the screen, the server binds the handles to that menu's block entity, after chec instance of the declared `block-entity` class. An instance method on any other class has no instance to call and is rejected at load. +Indexed references support generated gauges for multiple tanks without one Java +getter per tank. For example, `value="#getTankFluid(0)" max="#getTankCapacity(0)"`. + Getters run only on the server, each tick while the screen is open. The client never calls them; it only receives the values. diff --git a/docs/xml-machines.md b/docs/xml-machines.md index ded75012..47dcfd00 100644 --- a/docs/xml-machines.md +++ b/docs/xml-machines.md @@ -1,6 +1,6 @@ # XML machines -An installed mod can declare a powered item-processing machine in +An installed mod can declare an item-processing machine with optional energy and fluid storage in `src/main/resources/mmdlib/machine/.xml`. Its ID is `:`. MMDLib registers the block, item, block-entity type, declared recipe type and serializer, capabilities, screen and JEI catalyst. All instances use the shared @@ -63,30 +63,31 @@ The companion's SimpleCrusher is in the **Data-defined Machines** creative tab. ## Base machine and storage -The library's separate machine definition -[`base_powered_machine.xml`](../src/main/resources/mmdlib/machine/base_powered_machine.xml) -provides: +The generic [`base_machine.xml`](../src/main/resources/mmdlib/machine/base_machine.xml) +provides block settings, redstone control, recovery and the common screen with generated +tank fills. It declares no storage. The powered base extends it: ```xml - - + - - ``` +The Java runtime follows the same split: `BaseMachine` owns the common lifecycle; +`BasePoweredMachine` adds an FE buffer for existing Java machines. XML machines use +`DefinedMachineEntity`, allocating an FE buffer only when `` is declared. + Machine and screen definitions have separate namespaces of IDs, so these two `mmdlib:base_powered_machine` resources serve different purposes. The machine inherits common configuration; its screen inherits the reusable visual template. -Abstract machines register nothing. Single-valued sections (`items`, `energy`, +Abstract machines register nothing. Single-valued sections (`items`, `fluids`, `energy`, `recipe-type`, `processor`, and the other machine sections) merge by element name, so a child may change their IDs. Repeated slots/groups merge by element name and `id`; fills merge by `name`. A child can override just the energy capacity while @@ -117,23 +118,116 @@ modes are respectively `input`, `output`, `both`, and `disabled`. A group cannot grant automation permissions that its members' roles prohibit. Slot `limit` defaults to 64. There must be 1–54 item slots overall. -The initial runtime supports one item storage, the inherited energy buffer -named `energy`, one recipe type and one processor per machine. Energy automation +The runtime supports one item storage, an optional fluid storage, an optional energy +buffer named `energy`, one recipe type and one processor per machine. Energy automation uses the `energy/input` group. Item storage IDs `energy`, `features`, `automation`, `redstone_control`, `redstone_mode`, `id`, `components`, `mmdlib_features`, and -`mmdlib_machine` are reserved. Input-mode item groups are automatically linked to +`mmdlib_machine` are reserved. Input-mode item and fluid groups are automatically linked to the processor's recipes; an explicit `filter-inputs` list must name those groups. Processor `name` optionally overrides the displayed machine name. Stable storage IDs, processing reservations and side settings survive saves. Displaced resources use `mmdlib:machine_recovery_capsule`; use a capsule on an XML -machine to restore compatible items and energy. Overflow and unknown resource +machine to restore compatible items, fluids and energy. Overflow and unknown resource entries remain in the capsule. +## Simple Lava Furnace + +[`simple_lava_furnace.xml`](../src/companion/resources/mmdlib/machine/simple_lava_furnace.xml) +extends `mmdlib:base_machine`, with one input slot, one output slot and this storage: + +```xml + + + +``` + +There is no base fluid machine. The furnace owns its tank definition and has no FE +buffer or FE capability. Right-click it with a lava bucket or fill it through its +fluid capability. The generated gauge and side controls use the `fuel` group. +Wrong fluids are rejected; a failed bucket interaction does not spill fluid into the world. + +Generated tank tooltips show the resource group name, current amount/fluid (or +`Empty`), and accepted fluid names. Carry a filled fluid container on the cursor +to see a blue `Accepts Lava in Bucket` or red `Doesn't accept Water in Tank` line. +Modded containers use their display name; empty containers add no compatibility +line. The server evaluates each viewer's carried stack against the tank's role, +recipe filter and current fluid, using simulation only. Acceptance describes +fluid compatibility, not free capacity. These lines update when recipes reload. + +Both `` and `` create automatic groups. The latter expands +stable tank IDs to `fuel/0`, `fuel/1`, etc. Capacity is per tank, in mB, and must be +positive. Declare 1–16 tanks per fluid storage. The optional `fluid` attribute names +one registered fluid; omit it to allow any fluid accepted by the recipe filter. +Roles are `input`, `output`, `storage` or `manual`, as for slots. Explicit fluid +`` entries may override an automatic group's mode/color, but cannot regroup +its members. Item, fluid and energy group IDs must be distinct. Storage IDs must be +distinct and cannot use the lifecycle's reserved keys. An explicit `filter-inputs` +list includes fluid input groups: the furnace uses `filter-inputs="input fuel"`. +Fluid products, reservations, stable IDs, save/load, recovery and side automation +use the same machinery as Java machines. Paid automation requires an FE buffer; +free automation also works on machines without one. + +The generic base includes ``. +An explicit `…` replaces it (including an empty fill). +Generated gauges bind `#getTankFluid(0)` and `#getTankCapacity(0)`, then index 1, +and so on. Indexed references call a public instance getter with one fixed +nonnegative `int` argument on the server; its result is synchronized like an +ordinary getter. Invalid tank indexes fail startup screen validation. + +### Smelting and energy-to-fluid recipe adapters + +The furnace selects its mod-owned generator: + +```xml + +``` + +MMDLib's `SmeltingRecipeGenerator` adapts all existing `SmeltingRecipe` entries, +including recipes added by mods/datapacks. It preserves the input ingredient, +output stack (including count/components), and cooking duration. This includes +non-ore smelting, such as sand to glass. Furnace experience rewards are not copied. +The optional `input-group`, `output-group`, and `energy-group` default to `input`, +`output`, and `energy`; `energy-per-tick` defaults to 20. + +The companion's [`LavaSmeltingRecipes`](../src/companion/java/zone/moddev/mc/companion/recipe/LavaSmeltingRecipes.java) +subclasses this generator and wraps its output. It replaces FE with lava before +emitting recipes for validation and publication. Optional `fluid-group` defaults +to `fuel` and `energy-per-mb` defaults to 40. Thus a 200-tick vanilla recipe costs +100 mB lava and yields the original one ingot. Generated IDs have the form +`:generated//smelting//`. +Authored recipes can override these IDs; `/reload` rebuilds them from current recipes. + +The same pattern works with `OreToIngotGenerator` directly: + +```java +public final class LavaOres extends OreToIngotGenerator { + @Override + public void generate(RecipeGenerationContext ctx, MachineRecipeOutput output) { + long rate = ctx.properties().longValue("energy-per-mb", 40, 1, Long.MAX_VALUE); + var lava = ctx.registries().lookupOrThrow(Registries.FLUID) + .getOrThrow(Fluids.LAVA.builtInRegistryHolder().key()); + super.generate(ctx.withProperties(ctx.properties().without("energy-per-mb")), + (id, recipe) -> output.accept(id, RecipeTransforms.energyToFluid( + recipe, "fuel", HolderSet.direct(lava), rate))); + } +} +``` + +Use holders from the context's registry lookup: reload candidates may have a +different holder owner from the globally registered fluid. +`GeneratorProperties.without(...)` removes only the subclass's custom options so +its parent still rejects misspelled options. `RecipeTransforms.energyToFluid` +sums all energy inputs with overflow checks, removes them, and adds one fluid +input using `ceil(total FE / FE per mB)`. It preserves other inputs, outputs and +duration. Recipes with no FE inputs remain unchanged. This ore subclass retains +the ore generator's default 1 → 2 yield and hardness/tool scaling; the furnace's +smelting adapter preserves the existing smelting yield instead. + ## Generated screen fills -The base screen supplies the power gauge, player inventory, processor button, -side/redstone controls, Info and Statistics. Its machine-specific region is: +The generic base screen supplies player inventory, a processor button, +side/redstone controls, Info and Statistics. The powered screen adds the power gauge. Its machine-specific region is: ```xml @@ -178,6 +272,7 @@ Custom screen templates can use these fragment generators on their placeholders: | `mmdlib:processing` | None | Complete processor region, excluding inherited frame/controls | | `mmdlib:resource-groups` | `role="input\|output\|storage"`, optional `columns="1..9"` (default 3) | Slots for matching groups; storage includes manual groups | | `mmdlib:processors` | None | The single processor's progress arrow | +| `mmdlib:tanks` | None | One indexed fluid gauge per tank, in declaration order | Register custom namespaced builders with `MachineScreenGenerators.register` before definitions load. A builder receives the resolved `MachineDefinition` @@ -362,6 +457,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 powered item processing and cube appearances. XML fluid -storage, multiple processors, world-interaction features and standalone loose -content packs remain outside this format's implemented scope. +This delivery covers item processing with optional FE and fluids, and cube 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 56625617..61cd2b57 100644 --- a/scripts/companion.gradle +++ b/scripts/companion.gradle @@ -76,6 +76,14 @@ neoForge { systemProperty 'mmdlib.simpleCrusherSmoke', 'true' programArguments.addAll '--width', '1280', '--height', '960' } + simpleLavaFurnaceClientSmoke { + client() + gameDirectory = layout.buildDirectory.dir('simple-lava-furnace-client-smoke') + sourceSet = sourceSets.companionTest + loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] + systemProperty 'mmdlib.simpleLavaFurnaceSmoke', 'true' + programArguments.addAll '--width', '1280', '--height', '960' + } } } @@ -92,6 +100,19 @@ tasks.matching { it.name == 'runSimpleCrusherClientSmoke' }.configureEach { } } +tasks.matching { it.name == 'runSimpleLavaFurnaceClientSmoke' }.configureEach { + doFirst { + def smokeDir = layout.buildDirectory.dir('simple-lava-furnace-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('simple-lava-furnace-client-smoke/logs/latest.log').get().asFile + if (!log.exists() || !log.text.contains('SIMPLE_LAVA_FURNACE_CLIENT_PASS') || log.text.contains('SIMPLE_LAVA_FURNACE_CLIENT_FAIL')) + throw new GradleException('SimpleLavaFurnace client smoke failed; inspect ' + log) + } +} + def companionJar = tasks.register('companionJar', Jar) { group = 'build' description = 'Builds the optional six-machine companion mod.' diff --git a/src/companion/java/zone/moddev/mc/companion/recipe/LavaSmeltingRecipes.java b/src/companion/java/zone/moddev/mc/companion/recipe/LavaSmeltingRecipes.java new file mode 100644 index 00000000..2d02e0a8 --- /dev/null +++ b/src/companion/java/zone/moddev/mc/companion/recipe/LavaSmeltingRecipes.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.companion.recipe; + +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.material.Fluids; + +import zone.moddev.mc.lib.machine.recipe.generation.*; + +/** Mod-owned adapter: the library emits energy recipes; this mod chooses lava as the fuel. */ +public final class LavaSmeltingRecipes extends SmeltingRecipeGenerator { + @Override + public void generate(RecipeGenerationContext ctx, MachineRecipeOutput output) { + var p = ctx.properties(); + String group = p.string("fluid-group", "fuel"); + long rate = p.longValue("energy-per-mb", 40, 1, Long.MAX_VALUE); + var lava = + ctx.registries() + .lookupOrThrow(Registries.FLUID) + .getOrThrow(Fluids.LAVA.builtInRegistryHolder().key()); + super.generate( + ctx.withProperties(p.without("fluid-group", "energy-per-mb")), + (id, recipe) -> + output.accept( + id, RecipeTransforms.energyToFluid(recipe, group, HolderSet.direct(lava), rate))); + } +} diff --git a/src/companion/resources/data/mmdlib_companion/recipe/simple_lava_furnace.json b/src/companion/resources/data/mmdlib_companion/recipe/simple_lava_furnace.json new file mode 100644 index 00000000..788d226a --- /dev/null +++ b/src/companion/resources/data/mmdlib_companion/recipe/simple_lava_furnace.json @@ -0,0 +1,10 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": ["NNN", "BFB", "NNN"], + "key": { + "N": {"item": "minecraft:nether_bricks"}, + "B": {"item": "minecraft:bucket"}, + "F": {"item": "minecraft:furnace"} + }, + "result": {"id": "mmdlib_companion:simple_lava_furnace", "count": 1} +} diff --git a/src/companion/resources/mmdlib/machine/simple_lava_furnace.xml b/src/companion/resources/mmdlib/machine/simple_lava_furnace.xml new file mode 100644 index 00000000..d88c5b2c --- /dev/null +++ b/src/companion/resources/mmdlib/machine/simple_lava_furnace.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java index d726e12d..2e06f152 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleCrusherClientSmoke.java @@ -24,20 +24,25 @@ /** Focused real-client check of XML registration, generated layout, recipes and /reload. */ @EventBusSubscriber(modid = "mmdlib_companion", value = Dist.CLIENT) public final class SimpleCrusherClientSmoke { + private static final boolean LAVA = Boolean.getBoolean("mmdlib.simpleLavaFurnaceSmoke"); + private static final String NAME = LAVA ? "simple-lava-furnace" : "simple-crusher"; + private static final String MARKER = LAVA ? "SIMPLE_LAVA_FURNACE" : "SIMPLE_CRUSHER"; private static final ResourceLocation ID = - ResourceLocation.parse("mmdlib_companion:simple_crusher"); + ResourceLocation.parse( + "mmdlib_companion:" + (LAVA ? "simple_lava_furnace" : "simple_crusher")); private static final ResourceLocation TYPE = - ResourceLocation.parse("mmdlib_companion:simple_crushing"); + ResourceLocation.parse("mmdlib_companion:" + (LAVA ? "lava_smelting" : "simple_crushing")); 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, reloadRequested; private static volatile boolean reloaded; private static boolean recipesOpened; private static int frames; + private static int tooltipStage; @SubscribeEvent public static void tick(ClientTickEvent.Post event) { - if (!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") || finished) return; + if ((!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") && !LAVA) || finished) return; var mc = Minecraft.getInstance(); try { if (System.currentTimeMillis() > DEADLINE) @@ -46,7 +51,7 @@ public static void tick(ClientTickEvent.Post event) { started = true; var settings = new LevelSettings( - "SimpleCrusher smoke", + NAME + " smoke", GameType.CREATIVE, false, Difficulty.PEACEFUL, @@ -55,7 +60,7 @@ public static void tick(ClientTickEvent.Post event) { WorldDataConfiguration.DEFAULT); mc.createWorldOpenFlows() .createFreshLevel( - "simple-crusher-" + System.currentTimeMillis(), + NAME + "-" + System.currentTimeMillis(), settings, new WorldOptions(1, false, false), registries -> @@ -85,7 +90,12 @@ public static void tick(ClientTickEvent.Post event) { player.teleportTo(.5, 100, 3.5); level.setBlock(POS, BuiltInRegistries.BLOCK.get(ID).defaultBlockState(), 3); var machine = (DefinedMachineEntity) level.getBlockEntity(POS); - machine.energy.setStored(90000); + if (LAVA) + machine.fluids.fill( + new net.neoforged.neoforge.fluids.FluidStack( + net.minecraft.world.level.material.Fluids.LAVA, 4000), + net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction.EXECUTE); + else machine.energy.setStored(90000); machine.items.insertItem(0, new ItemStack(Items.IRON_ORE, 32), false); for (int i = 0; i < 31; i++) machine.serverTick(); } catch (Throwable error) { @@ -113,15 +123,16 @@ public static void tick(ClientTickEvent.Post event) { @SubscribeEvent public static void rendered(RenderFrameEvent.Post event) { - if (!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") || finished) return; + if ((!Boolean.getBoolean("mmdlib.simpleCrusherSmoke") && !LAVA) || finished) return; var mc = Minecraft.getInstance(); if (recipesOpened) { if (++frames < 10) return; finished = true; Screenshot.grab( - mc.gameDirectory, "simple-crusher-recipes.png", mc.getMainRenderTarget(), message -> {}); + mc.gameDirectory, NAME + "-recipes.png", mc.getMainRenderTarget(), message -> {}); MMDLib.LOGGER.info( - "SIMPLE_CRUSHER_CLIENT_PASS: centered screen, arrow recipe click, assets, synchronized" + MARKER + + "_CLIENT_PASS: centered screen, arrow recipe click, assets, synchronized" + " recipes and reload"); mc.stop(); return; @@ -136,18 +147,76 @@ public static void rendered(RenderFrameEvent.Post event) { throw new AssertionError("Progress maximum not synced"); if (MachineContent.recipeType(TYPE).recipes(mc.level.getRecipeManager()).isEmpty()) throw new AssertionError("Generated recipes not synced"); - for (var name : java.util.List.of("cobblestone", "gravel")) - if (mc.level - .getRecipeManager() - .byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone/" + name)) - .isEmpty()) throw new AssertionError("Bundled recipe not synchronized: " + name); + if (LAVA) { + var gauge = menu.bound.screen().find("tank_0").orElseThrow(); + var gaugeBounds = screen.bounds(gauge); + org.lwjgl.glfw.GLFW.glfwSetCursorPos( + mc.getWindow().getWindow(), + (gaugeBounds.x() + gaugeBounds.width() / 2.) + * mc.getWindow().getScreenWidth() + / mc.getWindow().getGuiScaledWidth(), + (gaugeBounds.y() + gaugeBounds.height() / 2.) + * mc.getWindow().getScreenHeight() + / mc.getWindow().getGuiScaledHeight()); + if (!(menu.value(gauge, false) instanceof net.neoforged.neoforge.fluids.FluidStack fluid) + || fluid.getAmount() <= 0 + || fluid.getFluid() != net.minecraft.world.level.material.Fluids.LAVA) + throw new AssertionError("Lava gauge did not synchronize"); + if (menu.bound.screen().find("energy").isPresent()) + throw new AssertionError("Unexpected FE gauge"); + var tooltip = screen.tooltipLines(gauge, 0, 0); + if (tooltip.size() < 3 + || !tooltip.get(0).getString().equals("fuel") + || !tooltip.get(1).getString().contains("mB of Lava") + || !tooltip.get(2).getString().equals("Accepts: Lava")) + throw new AssertionError("Tank tooltip did not synchronize: " + tooltip); + if (tooltipStage < 3) { + if (tooltipStage > 0) { + String expected = + tooltipStage == 1 ? "Accepts Lava in Bucket" : "Doesn't accept Water in Bucket"; + var color = + tooltipStage == 1 + ? net.minecraft.ChatFormatting.BLUE + : net.minecraft.ChatFormatting.RED; + if (!tooltip.getLast().getString().equals(expected) + || tooltip.getLast().getStyle().getColor().getValue() != color.getColor()) + throw new AssertionError("Wrong carried-container tooltip: " + tooltip); + Screenshot.grab( + mc.gameDirectory, + NAME + "-tooltip-" + tooltipStage + ".png", + mc.getMainRenderTarget(), + message -> {}); + } + final ItemStack next = + tooltipStage == 0 + ? new ItemStack(Items.LAVA_BUCKET) + : tooltipStage == 1 ? new ItemStack(Items.WATER_BUCKET) : ItemStack.EMPTY; + mc.getSingleplayerServer() + .execute( + () -> + mc.getSingleplayerServer() + .getPlayerList() + .getPlayers() + .getFirst() + .containerMenu + .setCarried(next)); + tooltipStage++; + frames = 0; + return; + } + } + if (!LAVA) + for (var name : java.util.List.of("cobblestone", "gravel")) + if (mc.level + .getRecipeManager() + .byKey(ResourceLocation.parse("mmdlib_companion:simple_crushing/stone/" + name)) + .isEmpty()) throw new AssertionError("Bundled recipe not synchronized: " + name); var model = mc.getBlockRenderer().getBlockModel(mc.level.getBlockState(POS)); if (model.getParticleIcon().contents().name().getPath().equals("missingno")) throw new AssertionError("Generated block model missing"); if (!reloadRequested) { reloadRequested = true; - Screenshot.grab( - mc.gameDirectory, "simple-crusher.png", mc.getMainRenderTarget(), message -> {}); + Screenshot.grab(mc.gameDirectory, NAME + ".png", mc.getMainRenderTarget(), message -> {}); var server = mc.getSingleplayerServer(); server.execute( () -> { @@ -181,7 +250,7 @@ public static void rendered(RenderFrameEvent.Post event) { } if (!reloaded) return; Screenshot.grab( - mc.gameDirectory, "simple-crusher-reloaded.png", mc.getMainRenderTarget(), message -> {}); + mc.gameDirectory, NAME + "-reloaded.png", mc.getMainRenderTarget(), message -> {}); var field = Class.forName("zone.moddev.mc.lib.client.jei.MachineJei").getDeclaredField("runtime"); field.setAccessible(true); @@ -192,17 +261,18 @@ public static void rendered(RenderFrameEvent.Post event) { if (runtime == null || runtime.getRecipeManager().createRecipeLookup(type).get().findAny().isEmpty()) throw new AssertionError("JEI crusher recipes unavailable"); - if (runtime - .getRecipeManager() - .createRecipeLookup(type) - .get() - .noneMatch( - holder -> - holder - .id() - .equals( - ResourceLocation.parse( - "mmdlib_companion:simple_crushing/stone/cobblestone")))) + if (!LAVA + && runtime + .getRecipeManager() + .createRecipeLookup(type) + .get() + .noneMatch( + holder -> + holder + .id() + .equals( + ResourceLocation.parse( + "mmdlib_companion:simple_crushing/stone/cobblestone")))) throw new AssertionError("Bundled recipe missing from JEI after reload"); var longestExpectedDetails = net.minecraft.network.chat.Component.translatable("jei.mmdlib.machine.energy", "2,900") @@ -210,8 +280,9 @@ public static void rendered(RenderFrameEvent.Post event) { .append( net.minecraft.network.chat.Component.translatable( "jei.mmdlib.machine.time", "7.25")); - if (runtime.getRecipeManager().getRecipeCategory(type).getWidth() - < mc.font.width(longestExpectedDetails)) + if (!LAVA + && runtime.getRecipeManager().getRecipeCategory(type).getWidth() + < mc.font.width(longestExpectedDetails)) throw new AssertionError("JEI category clips fractional processing time"); var bounds = screen.bounds(progress); if (!screen.mouseClicked( @@ -227,7 +298,7 @@ public static void rendered(RenderFrameEvent.Post event) { private static void fail(Throwable error) { finished = true; - MMDLib.LOGGER.error("SIMPLE_CRUSHER_CLIENT_FAIL", error); + MMDLib.LOGGER.error(MARKER + "_CLIENT_FAIL", error); Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); } diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java new file mode 100644 index 00000000..cd946c6f --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/SimpleLavaFurnaceTests.java @@ -0,0 +1,358 @@ +package zone.moddev.mc.companion.test; + +import java.util.*; + +import net.minecraft.core.*; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.gametest.framework.*; +import net.minecraft.nbt.*; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.*; +import net.minecraft.world.level.material.Fluids; + +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; +import net.neoforged.neoforge.gametest.*; + +import zone.moddev.mc.lib.api.automation.ResourceType; +import zone.moddev.mc.lib.gui.Screens; +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.neoforge.*; +import zone.moddev.mc.lib.machine.recipe.*; +import zone.moddev.mc.lib.machine.recipe.generation.*; + +@GameTestHolder("mmdlib_companion") +@PrefixGameTestTemplate(false) +public final class SimpleLavaFurnaceTests { + private static final ResourceLocation ID = + ResourceLocation.parse("mmdlib_companion:simple_lava_furnace"); + private static final ResourceLocation TYPE = + ResourceLocation.parse("mmdlib_companion:lava_smelting"); + + private static DefinedMachineEntity place(GameTestHelper h) { + var pos = h.absolutePos(new BlockPos(2, 2, 2)); + h.getLevel().setBlock(pos, BuiltInRegistries.BLOCK.get(ID).defaultBlockState(), 3); + return (DefinedMachineEntity) h.getLevel().getBlockEntity(pos); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + + @GameTest(template = "empty") + public static void tankTooltipDescribesContentsAndEachViewersContainer(GameTestHelper h) { + var machine = place(h); + var gauge = Screens.get(ID).orElseThrow().screen().find("tank_0").orElseThrow(); + var empty = machine.screenTankTooltip(gauge, ItemStack.EMPTY); + require(empty.size() == 3 && empty.getFirst().getString().equals("fuel"), "Group name first"); + require( + ((net.minecraft.network.chat.contents.TranslatableContents) empty.get(1).getContents()) + .getKey() + .equals("screen.mmdlib.tank.empty"), + "Empty tank described"); + machine.fluids.fill(new FluidStack(Fluids.LAVA, 100), FluidAction.EXECUTE); + var lava = new ItemStack(Items.LAVA_BUCKET); + var water = new ItemStack(Items.WATER_BUCKET); + var allowed = machine.screenTankTooltip(gauge, lava); + var rejected = machine.screenTankTooltip(gauge, water); + var contents = + (net.minecraft.network.chat.contents.TranslatableContents) allowed.get(1).getContents(); + require( + contents.getKey().equals("screen.mmdlib.tank.contents") + && contents.getArgs()[0].equals(100), + "Actual fluid amount"); + require( + allowed.getLast().getStyle().getColor().getValue() + == net.minecraft.ChatFormatting.BLUE.getColor(), + "Accepted container is blue"); + require( + rejected.getLast().getStyle().getColor().getValue() + == net.minecraft.ChatFormatting.RED.getColor(), + "Rejected container is red"); + require( + lava.is(Items.LAVA_BUCKET) + && water.is(Items.WATER_BUCKET) + && machine.getTankFluid(0).getAmount() == 100, + "Tooltip never transfers contents"); + require( + machine.screenTankTooltip(gauge, new ItemStack(Items.BUCKET)).size() == 3, + "Empty containers add no misleading fluid line"); + var portable = new ItemStack(Items.GLASS_BOTTLE); + portable.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Tank")); + net.neoforged.neoforge.fluids.FluidUtil.getFluidHandler(portable) + .orElseThrow() + .fill(new FluidStack(Fluids.WATER, 350), FluidAction.EXECUTE); + var tankLine = machine.screenTankTooltip(gauge, portable).getLast(); + var tankText = + (net.minecraft.network.chat.contents.TranslatableContents) tankLine.getContents(); + require( + tankText.getKey().equals("screen.mmdlib.tank.container_rejects") + && ((net.minecraft.network.chat.Component) tankText.getArgs()[1]) + .getString() + .equals("Tank"), + "Modded containers use their own display name"); + require( + net.neoforged.neoforge.fluids.FluidUtil.getFluidHandler(portable) + .orElseThrow() + .getFluidInTank(0) + .getAmount() + == 350, + "Portable tank remains unchanged"); + var player = h.makeMockServerPlayerInLevel(); + var first = new zone.moddev.mc.lib.gui.runtime.MachineMenu(81, player.getInventory(), machine); + var second = new zone.moddev.mc.lib.gui.runtime.MachineMenu(82, player.getInventory(), machine); + first.setCarried(lava); + second.setCarried(water); + require( + !first.tooltipSnapshot().equals(second.tooltipSnapshot()), + "Viewer-specific cursor is not shared between menus"); + first.setCarried(ItemStack.EMPTY); + require( + first.tooltipSnapshot().values().stream().allMatch(value -> ((List) value).size() == 3), + "Removing cursor clears acceptance line"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void smeltsWithLavaAndResumesReservedCycle(GameTestHelper h) { + var machine = place(h); + require( + machine.getClass() == DefinedMachineEntity.class && machine.energy == null, + "Shared runtime without FE storage"); + require( + h.getLevel() + .getCapability( + Capabilities.EnergyStorage.BLOCK, machine.getBlockPos(), Direction.NORTH) + == null, + "No FE capability"); + var fluid = + h.getLevel() + .getCapability(Capabilities.FluidHandler.BLOCK, machine.getBlockPos(), Direction.NORTH); + require(fluid != null && fluid.getTankCapacity(0) == 8000, "Declared fluid capability"); + require( + fluid.fill(new FluidStack(Fluids.WATER, 1000), FluidAction.EXECUTE) == 0, "Rejects water"); + require( + fluid.fill(new FluidStack(Fluids.LAVA, 1000), FluidAction.EXECUTE) == 1000, "Accepts lava"); + require( + fluid.drain(1000, FluidAction.EXECUTE).isEmpty(), "Input tank refuses external extraction"); + machine.items.insertItem(0, new ItemStack(Items.RAW_IRON), false); + for (int i = 0; i < 26; i++) machine.serverTick(); + require(machine.getProgress() == 25 && machine.getTime() == 200, "Preserves smelting time"); + var saved = machine.saveWithoutMetadata(h.getLevel().registryAccess()); + machine.loadWithComponents(saved, h.getLevel().registryAccess()); + for (int i = 0; i < 175; i++) machine.serverTick(); + require(machine.items.getStackInSlot(0).isEmpty(), "Consumed input once"); + require( + machine.items.getStackInSlot(1).is(Items.IRON_INGOT) + && machine.items.getStackInSlot(1).getCount() == 1, + "Preserves original smelting yield"); + require( + machine.fluids.getFluidInTank(0).getAmount() == 900, + "Consumes 100 mB once across save/load"); + var screen = Screens.get(ID).orElseThrow().screen(); + require( + screen.find("tank_0").isPresent() && screen.find("energy").isEmpty(), + "Generated tank gauge without FE gauge"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void noFuelBlockedOutputAndPauseKeepInputs(GameTestHelper h) { + var machine = place(h); + machine.items.insertItem(0, new ItemStack(Items.RAW_IRON), false); + for (int i = 0; i < 10; i++) machine.serverTick(); + require( + machine.getProgress() == 0 && machine.items.getStackInSlot(0).getCount() == 1, + "No fuel leaves input intact"); + machine.fluids.fill(new FluidStack(Fluids.LAVA, 1000), FluidAction.EXECUTE); + machine.items.setStackInSlot(1, new ItemStack(Items.GOLD_INGOT, 64)); + for (int i = 0; i < 10; i++) machine.serverTick(); + require( + machine.getProgress() == 0 && machine.fluids.getFluidInTank(0).getAmount() == 1000, + "Blocked output consumes no lava"); + machine.items.setStackInSlot(1, ItemStack.EMPTY); + for (int i = 0; i < 21; i++) machine.serverTick(); + var processor = machine.features.processor(Optional.empty()).orElseThrow(); + processor.pause(); + for (int i = 0; i < 10; i++) machine.serverTick(); + require( + machine.getProgress() == 20 && machine.fluids.getFluidInTank(0).getAmount() == 1000, + "Pause releases reservation without consumption"); + processor.resume(); + for (int i = 0; i < 181; i++) machine.serverTick(); + require( + machine.items.getStackInSlot(1).is(Items.IRON_INGOT) + && machine.fluids.getFluidInTank(0).getAmount() == 900, + "Resume produces once"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void recoveryRestoresFluidAndRetainsEnergyWithoutBuffer(GameTestHelper h) { + var machine = place(h); + machine.fluids.fill(new FluidStack(Fluids.LAVA, 7950), FluidAction.EXECUTE); + var data = new CompoundTag(); + data.put("fluid", new FluidStack(Fluids.LAVA, 200).save(h.getLevel().registryAccess())); + var entry = new CompoundTag(); + entry.putString("type", ResourceType.FLUIDS.id().toString()); + entry.put("data", data); + var entries = new ListTag(); + entries.add(entry); + var payload = new CompoundTag(); + payload.put("resources", entries); + payload.putLong("refund", 100); + var capsule = MachineRecoveryItem.create(payload); + require(MachineRecoveryItem.restore(capsule, machine), "Restores available capacity"); + require(machine.fluids.getFluidInTank(0).getAmount() == 8000, "Filled available space"); + var remainder = + capsule.get(DataComponents.CUSTOM_DATA).copyTag().getCompound("mmdlib_recovery"); + require(remainder.getLong("refund") == 100, "No power storage preserves energy refund"); + require( + FluidStack.parseOptional( + h.getLevel().registryAccess(), + remainder + .getList("resources", Tag.TAG_COMPOUND) + .getCompound(0) + .getCompound("data") + .getCompound("fluid")) + .getAmount() + == 150, + "Fluid overflow retained"); + require( + !MachineRecoveryItem.restore(capsule, machine), "Repeated restore cannot duplicate fluids"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void bucketsFillAndInvalidSavedFluidIsPreservedForRecovery(GameTestHelper h) { + var machine = place(h); + var player = h.makeMockServerPlayerInLevel(); + player.getAbilities().instabuild = false; + var hit = + new net.minecraft.world.phys.BlockHitResult( + net.minecraft.world.phys.Vec3.atCenterOf(machine.getBlockPos()), + Direction.UP, + machine.getBlockPos(), + false); + player.setItemInHand( + net.minecraft.world.InteractionHand.MAIN_HAND, new ItemStack(Items.LAVA_BUCKET)); + var result = + machine + .getBlockState() + .useItemOn( + player.getMainHandItem(), + h.getLevel(), + player, + net.minecraft.world.InteractionHand.MAIN_HAND, + hit); + require( + result == net.minecraft.world.ItemInteractionResult.CONSUME + && player.getMainHandItem().is(Items.BUCKET) + && machine.getTankFluid(0).getAmount() == 1000, + "Bucket fills tank and returns empty bucket"); + player.setItemInHand( + net.minecraft.world.InteractionHand.MAIN_HAND, new ItemStack(Items.WATER_BUCKET)); + require( + machine + .getBlockState() + .useItemOn( + player.getMainHandItem(), + h.getLevel(), + player, + net.minecraft.world.InteractionHand.MAIN_HAND, + hit) + == net.minecraft.world.ItemInteractionResult.FAIL, + "Wrong fluid blocks vanilla bucket placement"); + require( + player.getMainHandItem().is(Items.WATER_BUCKET) + && machine.getTankFluid(0).getAmount() == 1000, + "Rejected bucket preserves contents"); + machine.fluids.restoreFluid(0, new FluidStack(Fluids.WATER, 500)); + var saved = machine.saveWithoutMetadata(h.getLevel().registryAccess()); + machine.loadWithComponents(saved, h.getLevel().registryAccess()); + require( + machine.getTankFluid(0).isEmpty() && !machine.fluids.recoveryContents().isEmpty(), + "Fluid rejected by changed tank filter is recoverable"); + h.succeed(); + } + + @GameTest(template = "empty") + public static void oreSubclassTransformsBeforeEmission(GameTestHelper h) { + var ore = BuiltInRegistries.ITEM.wrapAsHolder(Items.IRON_ORE); + var ingot = BuiltInRegistries.ITEM.wrapAsHolder(Items.IRON_INGOT); + var tags = + new ReloadTags( + Map.of( + ResourceLocation.parse("c:ores/iron"), + List.of(ore), + ResourceLocation.parse("c:ingots/iron"), + List.of(ingot))); + var context = + new RecipeGenerationContext( + MachineContent.recipeType(TYPE), + h.getLevel().registryAccess(), + tags, + List.of(), + new GeneratorProperties(Map.of("energy-per-mb", "40"), "test.xml", 1)); + var transformed = new ArrayList(); + new LavaOres().generate(context, (id, recipe) -> transformed.add(recipe)); + var result = transformed.getFirst(); + require( + result.time() == 100 && !result.inputs().containsKey("energy"), + "Subclass removes FE and preserves ore time"); + require( + ((MachineIngredient.FluidPart) result.inputs().get("fuel").getFirst()).amount() == 50, + "Ore energy converted to lava"); + require( + ((MachineProduct.ItemProduct) result.outputs().get("output").getFirst()).stack().getCount() + == 2, + "Ore generator still doubles output"); + var mixed = + new MachineRecipe( + Map.of( + "input", + List.of( + new MachineIngredient.EnergyPart(41), + new MachineIngredient.ItemPart( + net.minecraft.world.item.crafting.Ingredient.of(Items.RAW_IRON), 1)), + "other", + List.of(new MachineIngredient.EnergyPart(40))), + result.outputs(), + 17); + var converted = + RecipeTransforms.energyToFluid( + mixed, "fuel", HolderSet.direct(Fluids.LAVA.builtInRegistryHolder()), 40); + require( + ((MachineIngredient.FluidPart) converted.inputs().get("fuel").getFirst()).amount() == 3 + && !converted.inputs().containsKey("other"), + "All energy groups summed and rounded up"); + require( + converted.inputs().get("input").size() == 1 + && converted.time() == 17 + && converted.outputs().equals(mixed.outputs()), + "Retains nonenergy inputs, outputs and duration"); + h.succeed(); + } + + public static final class LavaOres extends OreToIngotGenerator { + @Override + public void generate(RecipeGenerationContext ctx, MachineRecipeOutput output) { + long rate = ctx.properties().longValue("energy-per-mb", 40, 1, Long.MAX_VALUE); + super.generate( + ctx.withProperties(ctx.properties().without("energy-per-mb")), + (id, recipe) -> + output.accept( + id, + RecipeTransforms.energyToFluid( + recipe, + "fuel", + HolderSet.direct( + ctx.registries() + .lookupOrThrow(net.minecraft.core.registries.Registries.FLUID) + .getOrThrow(Fluids.LAVA.builtInRegistryHolder().key())), + rate))); + } + } +} 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 33899566..2f337963 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 @@ -151,7 +151,7 @@ public void invalidateAddons() { public void refund(EnergyBuffer power, long amount) { if (amount < 0) throw new IllegalArgumentException("Negative refund"); if (amount == 0) return; - refund = Math.addExact(refund, amount - power.refund(amount)); + refund = Math.addExact(refund, amount - (power == null ? 0 : power.refund(amount))); owner.setChanged(); } @@ -160,7 +160,7 @@ public long pendingRefund() { } public void settleRefund(EnergyBuffer power) { - if (refund == 0) return; + if (refund == 0 || power == null) return; long restored = power.refund(refund); refund -= restored; if (restored != 0) owner.setChanged(); diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java index 85d863e4..bf8ab35a 100644 --- a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java +++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java @@ -246,6 +246,7 @@ public static void tick(AutomationInventory inventory, EnergyBuffer power) { inventory.recover(null); inventory.settleRefund(power); int total = inventory.groups().size() * COLUMNS; + if (total == 0) return; int start = (int) Math.floorMod(level.getGameTime(), total); for (int n = 0; n < total; n++) { int index = (start + n) % total, g = index / COLUMNS, c = index % COLUMNS; @@ -260,7 +261,8 @@ public static void tick(AutomationInventory inventory, EnergyBuffer power) { long cost = pending ? state.getLong("cost") : spec.energyPerUnit(); int batch = pending ? state.getInt("batch") : spec.batch(); if (!pending && Math.floorMod(level.getGameTime() + index, spec.interval()) != 0) continue; - int max = (int) Math.min(batch, cost == 0 ? batch : power.stored() / cost); + int max = + (int) Math.min(batch, cost == 0 ? batch : power == null ? 0 : power.stored() / cost); if (max <= 0) continue; var filter = inventory.filter(g, c); var predicate = filter.getCapability(AddonCapabilities.FILTER); @@ -294,7 +296,7 @@ public static void tick(AutomationInventory inventory, EnergyBuffer power) { state.putInt("face", face); } long reserved = cost * max; - if (!power.reserve(reserved)) break; + if (reserved > 0 && (power == null || !power.reserve(reserved))) break; int moved; try { moved = move(group, inventory, direction, input, pending, max, accepts, state); 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 676c3bd8..1988f5b4 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 @@ -412,6 +412,7 @@ public void deserializeNBT( continue; } if (!inRange(index) + || !tanks.get(index).isFluidValid(fluid) || (!tanks.get(index).isEmpty() && !FluidStack.isSameFluidSameComponents(tanks.get(index).getFluid(), fluid))) { recovery.add(entry.copy()); 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 9d5cb3c5..ae95b5b4 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 @@ -748,6 +748,7 @@ public List tooltipLines(Element e, int mx, int my) { boolean shift = hasShiftDown(); var text = shift ? c.tooltipShift().or(c::tooltip) : c.tooltip(); var lines = new ArrayList(); + lines.addAll(menu.tooltip(e)); text.ifPresent( t -> { String translated = diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java index 71fecd8b..a94c674e 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java +++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java @@ -25,6 +25,7 @@ *
    *
  • {@code #method}: a no-argument instance method of the block-entity class, a superclass or * an interface. + *
  • {@code #method(index)}: an instance getter taking one fixed nonnegative integer. *
  • {@code Class#method}: a static method taking one parameter the block entity can be passed * as. *
@@ -127,6 +128,7 @@ private static BoundValue resolve( Method method = switch (ref) { case ValueRef.Instance instance -> instanceMethod(owner, instance, source, line); + case ValueRef.Indexed indexed -> indexedMethod(owner, indexed, source, line); case ValueRef.Static fixed -> staticMethod(owner, fixed, loader, source, line); }; var kind = Kind.of(method.getReturnType()); @@ -141,6 +143,8 @@ private static BoundValue resolve( + describe(allowed)); try { MethodHandle handle = MethodHandles.publicLookup().unreflect(method); + if (ref instanceof ValueRef.Indexed indexed) + handle = MethodHandles.insertArguments(handle, 1, indexed.index()); return new BoundValue( ref, kind, handle.asType(MethodType.methodType(Object.class, Object.class))); } catch (IllegalAccessException ex) { @@ -165,6 +169,22 @@ private static Method instanceMethod( } } + private static Method indexedMethod( + Class owner, ValueRef.Indexed ref, String source, int line) { + try { + var method = owner.getMethod(ref.method(), int.class); + if (Modifier.isStatic(method.getModifiers())) + throw new ScreenException(source, line, ref.text() + " must be an instance getter"); + return method; + } catch (NoSuchMethodException ex) { + throw new ScreenException( + source, + line, + "No public indexed getter " + ref.method() + "(int) on " + owner.getName(), + ex); + } + } + private static Method staticMethod( Class owner, ValueRef.Static ref, ClassLoader loader, String source, int line) { var holder = load(ref.owner(), loader, source, line, "class"); diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/ValueRef.java b/src/main/java/zone/moddev/mc/lib/gui/model/ValueRef.java index 0a0b03b5..9189a579 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/ValueRef.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/ValueRef.java @@ -8,11 +8,22 @@ public sealed interface ValueRef { /** The reference as written in the screen file. */ String text(); - /** Parses {@code #method} or {@code fully.qualified.Class#method}. */ + /** Parses {@code #method}, {@code #method(index)} or {@code fully.qualified.Class#method}. */ static ValueRef parse(String text) { int hash = text.indexOf('#'); if (hash < 0 || hash != text.lastIndexOf('#') || hash == text.length() - 1) throw new IllegalArgumentException("Expected #method or Class#method: " + text); + if (hash == 0 && text.endsWith(")")) { + int open = text.indexOf('('); + if (open > 1) { + try { + int index = Integer.parseInt(text.substring(open + 1, text.length() - 1)); + if (index >= 0) return new Indexed(text.substring(1, open), index); + } catch (NumberFormatException ignored) { + } + } + throw new IllegalArgumentException("Expected #method(nonnegativeIndex): " + text); + } return hash == 0 ? new Instance(text.substring(1)) : new Static(text.substring(0, hash), text.substring(hash + 1)); @@ -31,6 +42,14 @@ public String text() { } } + /** A public instance getter taking one constant nonnegative integer, e.g. #getTankFluid(0). */ + record Indexed(String method, int index) implements ValueRef { + @Override + public String text() { + return "#" + method + "(" + index + ")"; + } + } + /** * {@code Class#method}: a public static method taking the block entity (or a supertype) as its * only parameter. 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 c753cd75..58d4c0ae 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 @@ -29,6 +29,28 @@ public class MachineMenu extends AbstractContainerMenu { /** Slot plans sync under this key plus the slot, above every element key. */ public static final int PLAN_KEYS = 1 << 20; + private static final int TOOLTIP_KEYS = 1 << 22; + + /** Server-authored, viewer-specific lines; getters and fluid filters never run on the client. */ + public List tooltip(Element element) { + var value = values.get(TOOLTIP_KEYS + index(element)); + if (!(value instanceof List list)) return List.of(); + return list.stream().filter(Component.class::isInstance).map(Component.class::cast).toList(); + } + + public Map tooltipSnapshot() { + var snapshot = new HashMap(); + if (host != null) + for (int i = 0; i < elements.size(); i++) + if (elements.get(i) instanceof Element.Gauge) + snapshot.put( + TOOLTIP_KEYS + i, + ScreenValues.snapshot( + host.screenTankTooltip(elements.get(i), getCarried().copy()), + blockEntity.getLevel().registryAccess())); + return snapshot; + } + public final BlockEntity blockEntity; public final ScreenBinder.Bound bound; public final List elements; @@ -658,6 +680,7 @@ public void broadcastChanges() { snapshot.put(Integer.MIN_VALUE + i, java.math.BigInteger.valueOf(selected)); } snapshot.putAll(reservationSnapshot()); + snapshot.putAll(tooltipSnapshot()); snapshot.putAll(stats.read(host, blockEntity.getLevel().getGameTime())); var delta = ScreenValues.changed(values, snapshot, initial); for (int key : widgetRefreshKeys) delta.put(key, snapshot.get(key)); 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 0a71b7ee..56989ff5 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 @@ -2,6 +2,7 @@ import java.util.Map; +import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; @@ -20,6 +21,12 @@ public interface ScreenHost { */ IItemHandlerModifiable screenItems(); + /** Per-viewer tooltip lines, sampled on the server with the item carried by this menu. */ + default java.util.List screenTankTooltip( + zone.moddev.mc.lib.gui.model.Element element, ItemStack carried) { + return java.util.List.of(); + } + /** Extra startup-definition identity checked alongside the screen before constructing a menu. */ default String screenDefinitionFingerprint() { return ""; 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 cda388fe..43a03bca 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 @@ -19,6 +19,7 @@ public record MachineDefinition( boolean requiresTool, Map textures, Power power, + Fluids fluids, String itemStorageId, List slots, List groups, @@ -37,6 +38,15 @@ public record Power(String id, long capacity, long input, long output, int color public record Slot(String id, Role role, int limit) {} + public record Tank(String id, Role role, int capacity, ResourceLocation fluid) {} + + public record Fluids(String id, List tanks, List groups) { + public Fluids { + tanks = List.copyOf(tanks); + groups = List.copyOf(groups); + } + } + public record Group(String id, List members, IoMode mode, int color) { 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 099e1f7c..b81bd543 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 @@ -122,6 +122,9 @@ private static Node merge(Node base, Node child) { Map.entry("appearance", Set.of("preset", "front", "side", "top", "bottom")), 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")), @@ -145,7 +148,9 @@ private static Node merge(Node base, Node child) { "recovery", "screen"), "storage", - Set.of("energy", "items"), + Set.of("energy", "items", "fluids"), + "fluids", + Set.of("tank", "tanks"), "items", Set.of("slot", "slots"), "groups", @@ -169,6 +174,7 @@ private static void validateTree(Node node) { case "appearance" -> List.of("front", "side", "top", "bottom"); case "recipe-type" -> List.of("id"); case "processor" -> List.of("recipe-type"); + case "tank", "tanks" -> List.of("fluid"); default -> List.of(); }; for (var attribute : resources) @@ -202,16 +208,45 @@ private static MachineDefinition plan( ? resource(appearance, "bottom").toString() : textures.get("side")); var storage = one(root, "storage"); - var power = one(storage, "energy"); + var power = optional(storage, "energy"); var items = one(storage, "items"); if (required(items, "id").equals("energy") || MachineStorageKeys.RESERVED.contains(required(items, "id"))) throw error(items, "Reserved storage ID " + required(items, "id")); - String powerId = required(power, "id"); + String powerId = power == null ? null : required(power, "id"); // BasePoweredMachine owns this stable save key. - if (!powerId.equals("energy")) + if (powerId != null && !powerId.equals("energy")) throw error(power, "Powered machines require energy id='energy'"); int energyColor = 0xd85b5b; + var fluidNode = optional(storage, "fluids"); + var tanks = new ArrayList(); + var fluidGroups = new LinkedHashMap(); + var tankIds = new HashMap(); + if (fluidNode != null) { + String fluidId = required(fluidNode, "id"); + if (fluidId.equals("energy") + || fluidId.equals(required(items, "id")) + || MachineStorageKeys.RESERVED.contains(fluidId)) + throw error(fluidNode, "Duplicate or reserved storage ID " + fluidId); + for (var node : fluidNode.children()) { + String tankId = required(node, "id"); + Role role = role(node); + 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") ? resource(node, "fluid") : null; + var members = new ArrayList(); + for (int i = 0; i < count; i++) { + String member = node.name().equals("tanks") ? tankId + "/" + i : tankId; + if (tankIds.putIfAbsent(member, tanks.size()) != null) + throw error(node, "Duplicate tank " + member); + members.add(tanks.size()); + tanks.add(new Tank(member, role, capacity, filter)); + } + if (fluidGroups.putIfAbsent(tankId, new Group(tankId, members, mode(role), 0xf0a13b)) + != 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 groups = new LinkedHashMap(); var ids = new HashMap(); @@ -259,6 +294,28 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 energyColor = color(node, energyColor); continue; } + if (fluidNode != null && required(node, "storage").equals(required(fluidNode, "id"))) { + var prior = fluidGroups.get(groupId); + if (prior == null) throw error(node, "Unknown automatic fluid group " + groupId); + if (node.attributes().containsKey("members")) { + var members = new ArrayList(); + for (var member : required(node, "members").split("\\s+")) { + if (!tankIds.containsKey(member)) throw error(node, "Unknown tank " + member); + members.add(tankIds.get(member)); + } + if (!prior.members().equals(members)) + throw error(node, "Conflicting automatic fluid group " + groupId); + } + IoMode mode; + try { + mode = IoMode.valueOf(required(node, "mode").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw error(node, "Invalid group mode"); + } + fluidGroups.put( + groupId, new Group(groupId, prior.members(), mode, color(node, prior.color()))); + continue; + } if (!required(node, "storage").equals(required(items, "id"))) throw error(node, "Unknown storage"); var prior = groups.get(groupId); @@ -294,6 +351,16 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 } if (covered.size() != slots.size()) throw error(items, "Every slot must belong to a group"); if (groups.containsKey(powerId)) throw error(items, "Item group conflicts with energy"); + for (var group : fluidGroups.values()) { + if (groups.containsKey(group.id()) || group.id().equals(powerId)) + throw error(fluidNode, "Duplicate resource group " + group.id()); + for (int index : group.members()) { + var role = tanks.get(index).role(); + if ((group.mode().canInsert() && !role.canInsert()) + || (group.mode().canExtract() && !role.canExtract())) + throw error(fluidNode, "Group mode conflicts with tank role: " + group.id()); + } + } var recipe = one(root, "recipe-type"); var generatorNode = optional(recipe, "generator"); Generator generator = null; @@ -320,9 +387,12 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 var processor = one(root, "processor"); if (!recipeId.equals(resource(processor, "recipe-type"))) throw error(processor, "Unknown recipe type"); - // All item input groups are linked automatically; an explicit list must agree. + // All input groups are linked automatically; an explicit list must agree. var inputs = - groups.values().stream().filter(g -> g.mode() == IoMode.INPUT).map(Group::id).toList(); + java.util.stream.Stream.concat(groups.values().stream(), fluidGroups.values().stream()) + .filter(g -> g.mode() == IoMode.INPUT) + .map(Group::id) + .toList(); if (processor.attributes().containsKey("filter-inputs") && !new HashSet<>(Arrays.asList(required(processor, "filter-inputs").split("\\s+"))) .equals(new HashSet<>(inputs))) @@ -345,12 +415,17 @@ slotId, new Group(slotId, members, mode, role == Role.INPUT ? 0x568bd8 : 0xe48f4 resource(block, "mineable"), bool(block, "requires-tool", false), textures, - new Power( - powerId, - number(power, "capacity", 1, Long.MAX_VALUE), - number(power, "input-rate", 0, Long.MAX_VALUE), - number(power, "output-rate", 0, Long.MAX_VALUE), - energyColor), + power == null + ? null + : new Power( + powerId, + number(power, "capacity", 1, Long.MAX_VALUE), + number(power, "input-rate", 0, Long.MAX_VALUE), + number(power, "output-rate", 0, Long.MAX_VALUE), + energyColor), + fluidNode == null + ? null + : new Fluids(required(fluidNode, "id"), tanks, List.copyOf(fluidGroups.values())), required(items, "id"), slots, List.copyOf(groups.values()), @@ -367,6 +442,23 @@ public static String required(Node node, String key) { return value; } + private static Role role(Node node) { + try { + return Role.valueOf(required(node, "role").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw error(node, "Invalid role"); + } + } + + private static IoMode mode(Role role) { + return switch (role) { + case INPUT -> IoMode.INPUT; + case OUTPUT -> IoMode.OUTPUT; + case STORAGE -> IoMode.BOTH; + case MANUAL -> IoMode.DISABLED; + }; + } + public static ResourceLocation resource(Node node, String key) { var value = required(node, key); var id = ResourceLocation.tryParse(value); 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 47181dd7..020cd27e 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 @@ -5,9 +5,11 @@ import net.minecraft.resources.ResourceLocation; import zone.moddev.mc.lib.api.capabilities.IoMode; +import zone.moddev.mc.lib.gui.load.ScreenException; import zone.moddev.mc.lib.gui.load.ScreenReader.Node; import zone.moddev.mc.lib.gui.model.Element; import zone.moddev.mc.lib.gui.model.Screen; +import zone.moddev.mc.lib.gui.model.ValueRef; /** Registered fragment builders; templates retain ownership of the enclosing screen. */ public final class MachineScreenGenerators { @@ -21,6 +23,7 @@ public interface Generator { static { register(ResourceLocation.parse("mmdlib:processing"), MachineScreenGenerators::processing); register(ResourceLocation.parse("mmdlib:resource-groups"), MachineScreenGenerators::resources); + register(ResourceLocation.parse("mmdlib:tanks"), MachineScreenGenerators::tanks); register( ResourceLocation.parse("mmdlib:processors"), (machine, node) -> { @@ -48,17 +51,30 @@ public static List generate(MachineDefinition machine, Node node) { /** Checks references against the resolved machine before any block can be placed. */ public static void validate(MachineDefinition machine, Screen screen) { for (var element : screen.elements()) { + if (element instanceof Element.EnergyGauge && machine.power() == null) + throw new ScreenException( + screen.source(element.common()), + element.common().line(), + "Energy gauge requires energy storage"); + for (var ref : List.of(element.common().value(), element.common().max())) + if (ref.orElse(null) instanceof ValueRef.Indexed indexed + && Set.of("getTankFluid", "getTankCapacity").contains(indexed.method()) + && (machine.fluids() == null || indexed.index() >= machine.fluids().tanks().size())) + throw new ScreenException( + screen.source(element.common()), + element.common().line(), + "Tank index outside machine storage: " + indexed.index()); if (element instanceof Element.Slot slot && slot.kind() != Element.SlotKind.GHOST && (slot.index() < 0 || slot.index() >= machine.slots().size())) - throw new zone.moddev.mc.lib.gui.load.ScreenException( + throw new ScreenException( screen.source(slot.common()), slot.common().line(), "Slot index outside machine storage: " + slot.index()); if (element instanceof Element.Button button && button.processorControl() && button.processorKey().filter(key -> !key.equals(machine.processor())).isPresent()) - throw new zone.moddev.mc.lib.gui.load.ScreenException( + throw new ScreenException( screen.source(button.common()), button.common().line(), "Unknown processor-key: " + button.processorKey().orElseThrow()); @@ -75,12 +91,25 @@ public static Map> compileHighlights( highlights.put(group.id(), new ArrayList<>()); group.members().forEach(index -> slots.put(index, group.id())); } - highlights.put(machine.power().id(), new ArrayList<>()); + if (machine.power() != null) highlights.put(machine.power().id(), new ArrayList<>()); + var tanks = new HashMap(); + if (machine.fluids() != null) + for (var group : machine.fluids().groups()) { + highlights.put(group.id(), new ArrayList<>()); + group.members().forEach(index -> tanks.put(index, group.id())); + } for (var element : screen.elements()) { var group = element instanceof Element.Slot slot ? slots.get(slot.index()) - : element instanceof Element.EnergyGauge ? machine.power().id() : null; + : element instanceof Element.EnergyGauge && machine.power() != null + ? machine.power().id() + : element instanceof Element.Gauge + && element.common().value().orElse(null) + instanceof ValueRef.Indexed indexed + && indexed.method().equals("getTankFluid") + ? tanks.get(indexed.index()) + : null; if (group != null) element.common().id().ifPresent(id -> highlights.get(group).add(id)); } highlights.replaceAll((key, ids) -> List.copyOf(ids)); @@ -151,6 +180,28 @@ private static List resources(MachineDefinition machine, Node node) { return groups(machine, node, mode, columns); } + private static List tanks(MachineDefinition machine, Node node) { + options(node, Set.of()); + var result = new ArrayList(); + if (machine.fluids() == null) return result; + for (int index = 0; index < machine.fluids().tanks().size(); index++) + result.add( + n( + node, + "gauge", + Map.of( + "id", + "tank_" + index, + "length", + "3s", + "value", + "#getTankFluid(" + index + ")", + "max", + "#getTankCapacity(" + index + ")"), + List.of())); + return result; + } + private static List groups(MachineDefinition machine, Node node, IoMode mode, int columns) { var result = new ArrayList(); for (var group : machine.groups()) 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 new file mode 100644 index 00000000..cedcbe4f --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BaseMachine.java @@ -0,0 +1,285 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.Clearable; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +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.api.automation.AutomationInventory; +import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; +import zone.moddev.mc.lib.api.capabilities.RelativeFace; +import zone.moddev.mc.lib.api.stats.StatsProvider; +import zone.moddev.mc.lib.common.runtime.DropsContents; +import zone.moddev.mc.lib.gui.runtime.ScreenHost; +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; +import zone.moddev.mc.lib.machine.feature.FeatureHost; +import zone.moddev.mc.lib.machine.feature.RecipeProcessor; + +/** + * Shared features, persistence and screen contract with optional power storage. Concrete screen XML + * can extend {@code mmdlib:base_machine}; inherited getters and the keyless processor button work + * without per-machine bindings. Slot/tank definitions, recipes, capabilities and block registration + * remain the consumer's choices. + */ +public abstract class BaseMachine extends BlockEntity + implements ScreenHost, DropsContents, Clearable { + public static final ResourceLocation SCREEN_TEMPLATE = + ResourceLocation.parse("mmdlib:base_machine"); + private static final String CLIENT_DATA = "mmdlib_machine"; + + public final FeatureHost features = + new FeatureHost(this::setChanged, () -> FeatureSync.send(this)); + + protected BaseMachine(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + + /** Optional power storage; fluid-fuel and unpowered machines return null. */ + protected EnergyBuffer powerStorage() { + return null; + } + + public Direction facing() { + return RelativeFace.facing(getBlockState()); + } + + public long getEnergy() { + return powerStorage() == null ? 0 : powerStorage().stored(); + } + + public long getEnergyCapacity() { + return powerStorage() == null ? 0 : powerStorage().capacity(); + } + + /** Common warnings for the inherited info panel; subclasses may append their own messages. */ + public List getWarnings() { + var warnings = new ArrayList(); + var redstone = screenRedstone(); + if (redstone != null && !pausePolicy().processingEnabled(redstone.paused())) + warnings.add(Component.translatable("screen.mmdlib.machine.redstone_waiting")); + for (var processor : features.processors()) { + var key = + switch (processor.status()) { + case STALLED -> "screen.mmdlib.machine.stalled"; + case BLOCKED -> "screen.mmdlib.machine.blocked"; + case HELD -> "screen.mmdlib.machine.held"; + default -> null; + }; + if (key != null) + warnings.add( + Component.translatable( + "screen.mmdlib.machine.processor_warning", + processor.displayName(), + Component.translatable(key))); + } + return List.copyOf(warnings); + } + + public int getProgress() { + return features.processor(java.util.Optional.empty()).map(RecipeProcessor::progress).orElse(0); + } + + public int getTime() { + return features.processor(java.util.Optional.empty()).map(RecipeProcessor::time).orElse(0); + } + + public boolean getPaused() { + return features + .processor(java.util.Optional.empty()) + .map(RecipeProcessor::paused) + .orElse(false); + } + + @Override + public final FeatureHost screenFeatures() { + return features; + } + + @Override + public List screenStats() { + var parts = new ArrayList(); + if (powerStorage() != null) parts.add(powerStorage()); + parts.addAll(features.statisticsProviders()); + return List.copyOf(parts); + } + + /** Opens the concrete screen registered under this entity type's ID. */ + public final boolean openScreen(Player player) { + return ScreenRuntime.open(player, this); + } + + /** + * Return the lifecycle configured by the subclass after its inventories are constructed. The base + * registers an optional energy buffer under {@code energy} automatically and rejects conflicting + * bindings. Without a lifecycle, the base persists energy and features only. + */ + protected MachineInventoryLifecycle machineInventory() { + return null; + } + + private MachineInventoryLifecycle configuredInventory() { + var inventory = machineInventory(); + if (inventory != null) inventory.bindPower(this, features, powerStorage()); + return inventory; + } + + /** Starts a lifecycle with this machine's optional power storage and feature host wired. */ + protected final MachineInventoryLifecycle genericInventory(AutomationInventory automation) { + var inventory = new MachineInventoryLifecycle(this, features, automation, powerStorage()); + return powerStorage() == null ? inventory : inventory.energy("energy", powerStorage()); + } + + @Override + public AutomationInventory screenAutomation() { + var inventory = configuredInventory(); + return inventory == null ? null : inventory.automation(); + } + + protected boolean processingAllowed() { + return true; + } + + protected MachineInventoryLifecycle.PausePolicy pausePolicy() { + return MachineInventoryLifecycle.PausePolicy.PROCESSING_ONLY; + } + + protected void beforeMachineTick() {} + + protected void afterMachineTick() {} + + public final void serverTick() { + if (level == null || level.isClientSide) return; + var redstone = screenRedstone(); + if (redstone != null) redstone.readInputs(); + beforeMachineTick(); + boolean paused = redstone != null && redstone.paused(); + var inventory = configuredInventory(); + if (inventory != null) inventory.tick(processingAllowed(), paused, pausePolicy()); + else features.tick(level, processingAllowed() && pausePolicy().processingEnabled(paused)); + afterMachineTick(); + if (redstone != null) redstone.updateOutputs(); + } + + /** Additional machine metadata; common inventories/features/redstone are already saved. */ + protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) {} + + /** Additional machine metadata; never called for a feature-only client update. */ + protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) {} + + /** + * Maps old numeric item/tank indices before storage and processor restoration. Stable IDs bypass + * it. + */ + protected java.util.function.IntUnaryOperator legacySlots(String key, CompoundTag tag) { + return java.util.function.IntUnaryOperator.identity(); + } + + /** + * Writes extra client state into a dedicated compound, separate from storage and feature data. + */ + protected void saveClientData(CompoundTag tag, HolderLookup.Provider registries) {} + + /** Reads extra client state after feature synchronization, never while loading a world save. */ + protected void loadClientData(CompoundTag tag, HolderLookup.Provider registries) {} + + /** + * Sends current feature and custom client state; call setChanged separately for persistent edits. + */ + protected final void syncToClient() { + FeatureSync.send(this); + } + + @Override + protected final void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { + super.saveAdditional(tag, registries); + var inventory = configuredInventory(); + if (inventory != null) inventory.save(tag, registries); + else { + if (powerStorage() != null) tag.put("energy", powerStorage().serializeNBT(registries)); + tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE))); + } + var redstone = screenRedstone(); + if (redstone != null) tag.put("redstone_control", redstone.save(registries)); + saveMachineData(tag, registries); + } + + @Override + protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { + if (FeatureSync.read(features, tag, registries)) { + if (tag.contains(CLIENT_DATA, Tag.TAG_COMPOUND)) + loadClientData(tag.getCompound(CLIENT_DATA), registries); + return; + } + super.loadAdditional(tag, registries); + var inventory = configuredInventory(); + if (inventory != null) inventory.load(tag, registries, this::legacySlots); + else { + features.reset(); + if (powerStorage() != null && tag.contains("energy")) + powerStorage().deserializeNBT(registries, tag.get("energy")); + if (tag.contains("features")) + features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features")); + } + var redstone = screenRedstone(); + if (redstone != null) { + if (tag.contains("redstone_control")) + redstone.load(tag.getCompound("redstone_control"), registries); + else { + int mode = tag.getInt("redstone_mode"); + redstone.migrate(mode >= 0 && mode <= 2 ? mode : 0); + } + } + loadMachineData(tag, registries); + } + + @Override + public final CompoundTag getUpdateTag(HolderLookup.Provider registries) { + var tag = FeatureSync.updateTag(features, registries); + var clientData = new CompoundTag(); + saveClientData(clientData, registries); + tag.put(CLIENT_DATA, clientData); + return tag; + } + + @Override + public final Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void clearContent() { + var inventory = configuredInventory(); + if (inventory != null) inventory.clear(); + else { + features.reset(); + if (powerStorage() != null) powerStorage().setStored(0); + setChanged(); + } + FeatureSync.send(this); + } + + @Override + public void dropContents(Level level, BlockPos pos) { + var inventory = configuredInventory(); + if (inventory != null) inventory.dropContents(payload -> recoverResources(level, pos, payload)); + } + + /** Preserve displaced resources in a consumer-owned recovery item or equivalent durable sink. */ + protected abstract void recoverResources(Level level, BlockPos pos, CompoundTag payload); +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java index 77d41539..976a69a6 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/BasePoweredMachine.java @@ -1,51 +1,18 @@ package zone.moddev.mc.lib.machine.neoforge; -import java.util.ArrayList; -import java.util.List; - import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.core.HolderLookup; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.Tag; -import net.minecraft.network.chat.Component; -import net.minecraft.network.protocol.Packet; -import net.minecraft.network.protocol.game.ClientGamePacketListener; -import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.Clearable; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.level.Level; -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.api.automation.AutomationInventory; import zone.moddev.mc.lib.api.capabilities.EnergyBuffer; -import zone.moddev.mc.lib.api.capabilities.RelativeFace; -import zone.moddev.mc.lib.api.stats.StatsProvider; -import zone.moddev.mc.lib.common.runtime.DropsContents; -import zone.moddev.mc.lib.gui.runtime.ScreenHost; -import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; -import zone.moddev.mc.lib.machine.feature.FeatureHost; -import zone.moddev.mc.lib.machine.feature.RecipeProcessor; -/** - * Shared power, features, persistence and screen contract for code-defined machines. Concrete - * screen XML can extend {@code mmdlib:base_powered_machine}; inherited getters and the keyless - * processor button work without per-machine bindings. Slot/tank definitions, recipes, capabilities - * and block registration remain the consumer's choices. - */ -public abstract class BasePoweredMachine extends BlockEntity - implements ScreenHost, DropsContents, Clearable { +/** Powered specialization of the common machine lifecycle, preserving the code-defined host API. */ +public abstract class BasePoweredMachine extends BaseMachine { public static final ResourceLocation SCREEN_TEMPLATE = ResourceLocation.parse("mmdlib:base_powered_machine"); - private static final String CLIENT_DATA = "mmdlib_machine"; - public final EnergyBuffer energy; - public final FeatureHost features = - new FeatureHost(this::setChanged, () -> FeatureSync.send(this)); protected BasePoweredMachine(BlockEntityType type, BlockPos pos, BlockState state) { this(type, pos, state, 100_000, 1_000, 0); @@ -62,230 +29,12 @@ protected BasePoweredMachine( energy = new EnergyBuffer(capacity, maxInput, maxOutput).onChange(this::setChanged); } - public Direction facing() { - return RelativeFace.facing(getBlockState()); - } - - public long getEnergy() { - return energy.stored(); - } - - public long getEnergyCapacity() { - return energy.capacity(); - } - - /** Common warnings for the inherited info panel; subclasses may append their own messages. */ - public List getWarnings() { - var warnings = new ArrayList(); - var redstone = screenRedstone(); - if (redstone != null && !pausePolicy().processingEnabled(redstone.paused())) - warnings.add(Component.translatable("screen.mmdlib.machine.redstone_waiting")); - for (var processor : features.processors()) { - var key = - switch (processor.status()) { - case STALLED -> "screen.mmdlib.machine.stalled"; - case BLOCKED -> "screen.mmdlib.machine.blocked"; - case HELD -> "screen.mmdlib.machine.held"; - default -> null; - }; - if (key != null) - warnings.add( - Component.translatable( - "screen.mmdlib.machine.processor_warning", - processor.displayName(), - Component.translatable(key))); - } - return List.copyOf(warnings); - } - - public int getProgress() { - return features.processor(java.util.Optional.empty()).map(RecipeProcessor::progress).orElse(0); - } - - public int getTime() { - return features.processor(java.util.Optional.empty()).map(RecipeProcessor::time).orElse(0); - } - - public boolean getPaused() { - return features - .processor(java.util.Optional.empty()) - .map(RecipeProcessor::paused) - .orElse(false); - } - @Override - public final FeatureHost screenFeatures() { - return features; + protected EnergyBuffer powerStorage() { + return energy; } - @Override - public List screenStats() { - var parts = new ArrayList(); - parts.add(energy); - parts.addAll(features.statisticsProviders()); - return List.copyOf(parts); - } - - /** Opens the concrete screen registered under this entity type's ID. */ - public final boolean openScreen(Player player) { - return ScreenRuntime.open(player, this); - } - - /** - * Return the lifecycle configured by the subclass after its inventories are constructed. The base - * registers its energy buffer under {@code energy} automatically and rejects conflicting - * bindings. Without a lifecycle, the base persists energy and features only. - */ - protected MachineInventoryLifecycle machineInventory() { - return null; - } - - private MachineInventoryLifecycle configuredInventory() { - var inventory = machineInventory(); - if (inventory != null) inventory.bindPower(this, features, energy); - return inventory; - } - - /** Starts a lifecycle with this base's power storage and feature host already wired. */ protected final MachineInventoryLifecycle poweredInventory(AutomationInventory automation) { - return new MachineInventoryLifecycle(this, features, automation, energy) - .energy("energy", energy); - } - - @Override - public AutomationInventory screenAutomation() { - var inventory = configuredInventory(); - return inventory == null ? null : inventory.automation(); - } - - protected boolean processingAllowed() { - return true; - } - - protected MachineInventoryLifecycle.PausePolicy pausePolicy() { - return MachineInventoryLifecycle.PausePolicy.PROCESSING_ONLY; + return genericInventory(automation); } - - protected void beforeMachineTick() {} - - protected void afterMachineTick() {} - - public final void serverTick() { - if (level == null || level.isClientSide) return; - var redstone = screenRedstone(); - if (redstone != null) redstone.readInputs(); - beforeMachineTick(); - boolean paused = redstone != null && redstone.paused(); - var inventory = configuredInventory(); - if (inventory != null) inventory.tick(processingAllowed(), paused, pausePolicy()); - else features.tick(level, processingAllowed() && pausePolicy().processingEnabled(paused)); - afterMachineTick(); - if (redstone != null) redstone.updateOutputs(); - } - - /** Additional machine metadata; common inventories/features/redstone are already saved. */ - protected void saveMachineData(CompoundTag tag, HolderLookup.Provider registries) {} - - /** Additional machine metadata; never called for a feature-only client update. */ - protected void loadMachineData(CompoundTag tag, HolderLookup.Provider registries) {} - - /** - * Maps old numeric item/tank indices before storage and processor restoration. Stable IDs bypass - * it. - */ - protected java.util.function.IntUnaryOperator legacySlots(String key, CompoundTag tag) { - return java.util.function.IntUnaryOperator.identity(); - } - - /** - * Writes extra client state into a dedicated compound, separate from storage and feature data. - */ - protected void saveClientData(CompoundTag tag, HolderLookup.Provider registries) {} - - /** Reads extra client state after feature synchronization, never while loading a world save. */ - protected void loadClientData(CompoundTag tag, HolderLookup.Provider registries) {} - - /** - * Sends current feature and custom client state; call setChanged separately for persistent edits. - */ - protected final void syncToClient() { - FeatureSync.send(this); - } - - @Override - protected final void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) { - super.saveAdditional(tag, registries); - var inventory = configuredInventory(); - if (inventory != null) inventory.save(tag, registries); - else { - tag.put("energy", energy.serializeNBT(registries)); - tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE))); - } - var redstone = screenRedstone(); - if (redstone != null) tag.put("redstone_control", redstone.save(registries)); - saveMachineData(tag, registries); - } - - @Override - protected final void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) { - if (FeatureSync.read(features, tag, registries)) { - if (tag.contains(CLIENT_DATA, Tag.TAG_COMPOUND)) - loadClientData(tag.getCompound(CLIENT_DATA), registries); - return; - } - super.loadAdditional(tag, registries); - var inventory = configuredInventory(); - if (inventory != null) inventory.load(tag, registries, this::legacySlots); - else { - features.reset(); - if (tag.contains("energy")) energy.deserializeNBT(registries, tag.get("energy")); - if (tag.contains("features")) - features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features")); - } - var redstone = screenRedstone(); - if (redstone != null) { - if (tag.contains("redstone_control")) - redstone.load(tag.getCompound("redstone_control"), registries); - else { - int mode = tag.getInt("redstone_mode"); - redstone.migrate(mode >= 0 && mode <= 2 ? mode : 0); - } - } - loadMachineData(tag, registries); - } - - @Override - public final CompoundTag getUpdateTag(HolderLookup.Provider registries) { - var tag = FeatureSync.updateTag(features, registries); - var clientData = new CompoundTag(); - saveClientData(clientData, registries); - tag.put(CLIENT_DATA, clientData); - return tag; - } - - @Override - public final Packet getUpdatePacket() { - return ClientboundBlockEntityDataPacket.create(this); - } - - @Override - public void clearContent() { - var inventory = configuredInventory(); - if (inventory != null) inventory.clear(); - else { - features.reset(); - energy.setStored(0); - setChanged(); - } - FeatureSync.send(this); - } - - @Override - public void dropContents(Level level, BlockPos pos) { - var inventory = configuredInventory(); - if (inventory != null) inventory.dropContents(payload -> recoverResources(level, pos, payload)); - } - - /** Preserve displaced resources in a consumer-owned recovery item or equivalent durable sink. */ - protected abstract void recoverResources(Level level, BlockPos pos, CompoundTag payload); } 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 a00eed11..4fff261d 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 @@ -14,6 +14,8 @@ import net.minecraft.world.level.block.state.properties.*; import net.minecraft.world.phys.BlockHitResult; +import net.neoforged.neoforge.fluids.FluidUtil; + import zone.moddev.mc.lib.common.runtime.DropsContents; /** Standard placement, interaction and lifecycle hooks for XML machines. */ @@ -91,6 +93,14 @@ protected ItemInteractionResult useItemOn( ? ItemInteractionResult.CONSUME : ItemInteractionResult.FAIL; } + if (level.getBlockEntity(pos) instanceof DefinedMachineEntity machine + && machine.fluids.getTanks() > 0 + && FluidUtil.getFluidHandler(stack).isPresent()) { + if (level.isClientSide()) return ItemInteractionResult.SUCCESS; + return FluidUtil.interactWithFluidHandler(player, hand, machine.fluids) + ? ItemInteractionResult.CONSUME + : ItemInteractionResult.FAIL; + } return ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION; } 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 5bb4c5ad..dab51c30 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 @@ -3,6 +3,7 @@ import java.util.*; import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -10,6 +11,8 @@ import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.fluids.FluidStack; + import zone.moddev.mc.lib.api.automation.*; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.api.redstone.RedstoneControl; @@ -18,9 +21,11 @@ import zone.moddev.mc.lib.machine.recipe.MachineRecipeType; /** Shared runtime assembled from XML. There is no per-machine Java subclass. */ -public final class DefinedMachineEntity extends BasePoweredMachine { +public final class DefinedMachineEntity extends BaseMachine { public final MachineDefinition definition; public final ItemStorage items; + public final FluidStorage fluids; + public final EnergyBuffer energy; public final MachineResources resources; public final MachineInventoryLifecycle inventory; private final RedstoneControl redstone; @@ -31,14 +36,29 @@ public DefinedMachineEntity( BlockState state, MachineDefinition definition, MachineRecipeType recipes) { - super( - type, - pos, - state, - definition.power().capacity(), - definition.power().input(), - definition.power().output()); + super(type, pos, state); this.definition = definition; + var power = definition.power(); + energy = + power == null + ? null + : new EnergyBuffer(power.capacity(), power.input(), power.output()) + .onChange(this::setChanged); + fluids = new FluidStorage().onChange(this::setChanged); + if (definition.fluids() != null) + for (var tank : definition.fluids().tanks()) { + var filter = tank.fluid(); + if (filter != null && !BuiltInRegistries.FLUID.containsKey(filter)) + throw new IllegalArgumentException("Unknown tank fluid " + filter); + int index = + fluids.addTank( + tank.capacity(), + tank.role(), + stack -> + filter == null + || BuiltInRegistries.FLUID.getKey(stack.getFluid()).equals(filter)); + fluids.tankId(index, tank.id()); + } items = new ItemStorage(definition.slots().size()).onChange(this::setChanged); for (int i = 0; i < definition.slots().size(); i++) { var slot = definition.slots().get(i); @@ -59,17 +79,33 @@ public DefinedMachineEntity( new SideConfig(group.mode())), items); } - resources.energy( - new ResourceGroup<>( - "energy", - Component.literal("Energy"), - definition.power().color(), - ResourceType.ENERGY, - IoMode.INPUT, - List.of(0), - highlights.get(definition.power().id()), - new SideConfig(IoMode.INPUT)), - energy); + if (energy != null) + resources.energy( + new ResourceGroup<>( + "energy", + Component.literal("Energy"), + definition.power().color(), + ResourceType.ENERGY, + IoMode.INPUT, + List.of(0), + highlights.get(definition.power().id()), + new SideConfig(IoMode.INPUT)), + energy); + if (definition.fluids() != null) + for (var group : definition.fluids().groups()) { + resources.fluids( + new ResourceGroup<>( + group.id(), + Component.literal(group.id()), + group.color(), + ResourceType.FLUIDS, + group.mode(), + group.members(), + highlights.get(group.id()), + new SideConfig(group.mode())), + fluids); + if (group.mode() == IoMode.INPUT) resources.recipeInputs(recipes, group.id()); + } for (var group : definition.groups()) if (group.mode() == IoMode.INPUT) resources.recipeInputs(recipes, group.id()); resources.build(); @@ -79,10 +115,72 @@ public DefinedMachineEntity( recipes, resources.recipes(), Component.literal(definition.processorName()))); - inventory = poweredInventory(resources.automation()).items(definition.itemStorageId(), items); + 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; } + @Override + protected EnergyBuffer powerStorage() { + return energy; + } + + public FluidStack getTankFluid(int index) { + return fluids.getFluidInTank(index).copy(); + } + + public int getTankCapacity(int index) { + return fluids.getTankCapacity(index); + } + + @Override + public List screenTankTooltip( + zone.moddev.mc.lib.gui.model.Element element, net.minecraft.world.item.ItemStack carried) { + if (!(element instanceof zone.moddev.mc.lib.gui.model.Element.Gauge) + || !(element.common().value().orElse(null) + instanceof zone.moddev.mc.lib.gui.model.ValueRef.Indexed getter) + || !getter.method().equals("getTankFluid") + || definition.fluids() == null) return List.of(); + int index = getter.index(); + if (index < 0 || index >= fluids.getTanks()) return List.of(); + var group = + definition.fluids().groups().stream() + .filter(g -> g.members().contains(index)) + .findFirst() + .orElseThrow(); + var tank = definition.fluids().tanks().get(index); + var name = + resources.automation().groups().stream() + .filter(g -> g.id().equals(group.id())) + .findFirst() + .orElseThrow() + .name(); + java.util.function.Predicate accepts = + stack -> + tank.role().canInsert() + && group.mode().canInsert() + && fluids.isFluidValid(index, stack); + var candidates = + tank.fluid() == null + ? BuiltInRegistries.FLUID.stream() + : java.util.stream.Stream.of(BuiltInRegistries.FLUID.get(tank.fluid())); + var accepted = + candidates + .filter( + fluid -> + fluid != net.minecraft.world.level.material.Fluids.EMPTY + && fluid.isSource(fluid.defaultFluidState())) + .filter( + fluid -> + tank.fluid() == null + || BuiltInRegistries.FLUID.getKey(fluid).equals(tank.fluid())) + .map(fluid -> new FluidStack(fluid, 1)) + .filter(accepts) + .map(FluidStack::getHoverName) + .toList(); + return FluidTankTooltip.lines(name, fluids.getFluidInTank(index), accepted, carried, accepts); + } + @Override public String screenDefinitionFingerprint() { return definition.checksum(); diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidTankTooltip.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidTankTooltip.java new file mode 100644 index 00000000..9d4f6d73 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/FluidTankTooltip.java @@ -0,0 +1,73 @@ +package zone.moddev.mc.lib.machine.neoforge; + +import java.util.*; +import java.util.function.Predicate; + +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.*; + +import net.neoforged.neoforge.fluids.*; +import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction; + +/** Fluid descriptions and carried-container compatibility; all checks are read-only. */ +public final class FluidTankTooltip { + private FluidTankTooltip() {} + + public static List lines( + Component group, + FluidStack current, + List accepted, + ItemStack carried, + Predicate accepts) { + var lines = new ArrayList(); + lines.add(group.copy().withStyle(ChatFormatting.GOLD)); + lines.add( + current.isEmpty() + ? Component.translatable("screen.mmdlib.tank.empty").withStyle(ChatFormatting.GRAY) + : Component.translatable( + "screen.mmdlib.tank.contents", current.getAmount(), current.getHoverName())); + var names = Component.empty(); + for (var name : accepted) { + if (!names.getSiblings().isEmpty()) names.append(", "); + names.append(name.copy()); + } + lines.add( + Component.translatable( + "screen.mmdlib.tank.accepts", + accepted.isEmpty() ? Component.translatable("screen.mmdlib.tank.none") : names) + .withStyle(ChatFormatting.BLUE)); + if (!carried.isEmpty()) + FluidUtil.getFluidHandler(carried.copy()) + .ifPresent( + handler -> { + Component container = + carried.getItem() instanceof BucketItem + ? Component.translatable(Items.BUCKET.getDescriptionId()) + : carried.getHoverName(); + var seen = new ArrayList(); + for (int tank = 0; tank < handler.getTanks(); tank++) { + var fluid = handler.getFluidInTank(tank); + if (fluid.isEmpty() + || seen.stream() + .anyMatch(old -> FluidStack.isSameFluidSameComponents(old, fluid))) + continue; + seen.add(fluid.copy()); + boolean allowed = + accepts.test(fluid) + && (current.isEmpty() + || FluidStack.isSameFluidSameComponents(current, fluid)) + && !handler.drain(fluid.copy(), FluidAction.SIMULATE).isEmpty(); + lines.add( + Component.translatable( + allowed + ? "screen.mmdlib.tank.container_accepts" + : "screen.mmdlib.tank.container_rejects", + fluid.getHoverName(), + container) + .withStyle(allowed ? ChatFormatting.BLUE : ChatFormatting.RED)); + } + }); + return List.copyOf(lines); + } +} 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 5e7ea469..04e0927c 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 @@ -3,10 +3,16 @@ import java.lang.reflect.Constructor; import java.util.*; +import com.google.gson.JsonElement; + import com.mojang.serialization.JsonOps; import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.Registry; import net.minecraft.core.registries.Registries; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.packs.resources.*; import net.minecraft.util.profiling.ProfilerFiller; @@ -95,9 +101,7 @@ protected void apply(Boolean prepared, ResourceManager manager, ProfilerFiller p * Builds a fresh candidate; exposed to runtime tests without altering the active recipe manager. */ public static List> generate( - List> authored, - net.minecraft.core.HolderLookup.Provider registries, - ReloadTags tags) { + List> authored, HolderLookup.Provider registries, ReloadTags tags) { var expanded = expand(authored); var generated = new TreeMap>(); var original = new TreeMap>(); @@ -119,7 +123,7 @@ public static List> generate( "Generated recipe must use namespace " + id.getNamespace()); // Round-trip validates every amount/product and takes an independent // snapshot. - var ops = registries.createSerializationContext(JsonOps.INSTANCE); + var ops = generationOps(registries); var encoded = MachineRecipe.CODEC.codec().encodeStart(ops, recipe).getOrThrow(); var snapshot = MachineRecipe.CODEC.codec().parse(ops, encoded).getOrThrow(); @@ -147,6 +151,28 @@ public static List> generate( 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()); + }); + } + }); + } + private static List> expand(List> authored) { var result = new TreeMap>(); for (var holder : authored) { @@ -195,11 +221,14 @@ private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) 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) && !key.equals(machine.power().id())) + if (!items.contains(key) && !fluids.contains(key) && !key.equals(energy)) throw new IllegalArgumentException( machine.id() + ": unknown recipe input group " + key); parts.forEach( @@ -207,8 +236,10 @@ private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) boolean valid = part instanceof MachineIngredient.ItemPart ? items.contains(key) - : part instanceof MachineIngredient.EnergyPart - && key.equals(machine.power().id()); + : 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); @@ -218,12 +249,14 @@ private static void validateGroups(MachineRecipeType type, MachineRecipe recipe) .outputs() .forEach( (key, parts) -> { - if (!items.contains(key)) + 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)) + 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 d6c04e31..252908ad 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 @@ -8,6 +8,7 @@ import net.minecraft.world.item.*; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.material.Fluids; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.capabilities.*; @@ -171,6 +172,19 @@ private static void registries(RegisterEvent event) { } 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()); for (var type : entities.values()) { event.registerBlockEntity( Capabilities.ItemHandler.BLOCK, @@ -178,15 +192,26 @@ private static void capabilities(RegisterCapabilitiesEvent event) { (entity, side) -> entity.items.view( index -> entity.resources.automation().mode(ResourceType.ITEMS, index, side))); + event.registerBlockEntity( + Capabilities.FluidHandler.BLOCK, + type, + (entity, side) -> + entity.fluids.getTanks() == 0 + ? null + : entity.fluids.view( + index -> + entity.resources.automation().mode(ResourceType.FLUIDS, index, side))); event.registerBlockEntity( Capabilities.EnergyStorage.BLOCK, type, (entity, side) -> - entity.energy.view( - () -> - side == null - ? IoMode.INPUT - : entity.resources.automation().mode(ResourceType.ENERGY, 0, side))); + entity.energy == null + ? null + : entity.energy.view( + () -> + side == null + ? IoMode.INPUT + : entity.resources.automation().mode(ResourceType.ENERGY, 0, side))); } } } 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 0a6edc75..8d36f130 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 @@ -52,7 +52,7 @@ public MachineInventoryLifecycle( this.owner = Objects.requireNonNull(owner); this.features = Objects.requireNonNull(features); this.automation = Objects.requireNonNull(automation); - this.power = Objects.requireNonNull(power); + this.power = power; } public MachineInventoryLifecycle items(String saveKey, ItemStorage storage) { @@ -75,8 +75,8 @@ public AutomationInventory automation() { void bindPower(BlockEntity owner, FeatureHost features, EnergyBuffer power) { if (this.owner != owner || this.features != features || this.power != power) throw new IllegalArgumentException( - "Powered lifecycle must use its machine's owner, features and energy"); - if (storages.get("energy") != power) add("energy", power); + "Lifecycle must use its machine's owner, features and optional energy"); + if (power != null && storages.get("energy") != power) add("energy", power); } private MachineInventoryLifecycle add(String key, Object storage) { diff --git a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java index 8502c3c7..ba6e5771 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java +++ b/src/main/java/zone/moddev/mc/lib/machine/neoforge/MachineRecoveryItem.java @@ -5,6 +5,9 @@ import net.minecraft.world.item.*; import net.minecraft.world.item.component.CustomData; +import net.neoforged.neoforge.fluids.FluidStack; +import net.neoforged.neoforge.fluids.capability.IFluidHandler; + import zone.moddev.mc.lib.api.automation.ResourceType; import zone.moddev.mc.lib.api.capabilities.Transfer; @@ -47,7 +50,17 @@ public static boolean restore(ItemStack stack, DefinedMachineEntity target) { else data.put("item", remaining.save(target.getLevel().registryAccess())); } } - } else if (type.equals(ResourceType.ENERGY.id().toString())) { + } else if (type.equals(ResourceType.FLUIDS.id().toString())) { + var fluid = + FluidStack.parseOptional(target.getLevel().registryAccess(), data.getCompound("fluid")); + int moved = target.fluids.fill(fluid, IFluidHandler.FluidAction.EXECUTE); + if (moved > 0) { + changed = true; + fluid.shrink(moved); + if (fluid.isEmpty()) data.remove("fluid"); + else data.put("fluid", fluid.save(target.getLevel().registryAccess())); + } + } else if (type.equals(ResourceType.ENERGY.id().toString()) && target.energy != null) { long amount = Math.max(0, data.getLong("energy")), moved = target.energy.insert(amount, false); if (moved > 0) { @@ -59,7 +72,7 @@ public static boolean restore(ItemStack stack, DefinedMachineEntity target) { if (data.isEmpty()) entries.remove(i); } long refund = Math.max(0, payload.getLong("refund")), - moved = target.energy.insert(refund, false); + moved = target.energy == null ? 0 : target.energy.insert(refund, false); if (moved > 0) changed = true; if (!changed) return false; if (moved == refund) payload.remove("refund"); diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java index 7ef2f6a8..f0908380 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/GeneratorProperties.java @@ -16,6 +16,13 @@ public void requireOnly(String... names) { if (!allowed.contains(name)) throw error("Unknown generator property " + name); } + /** Removes subclass-specific options before passing the remaining options to a generator. */ + public GeneratorProperties without(String... names) { + var remaining = new LinkedHashMap<>(values); + for (var name : names) remaining.remove(name); + return new GeneratorProperties(remaining, source, line); + } + public String requiredString(String name) { var value = values.get(name); if (value == null || value.isBlank()) throw error("Missing generator property " + name); diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java index f83fa0e4..5a0c54f5 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/OreToIngotGenerator.java @@ -15,7 +15,7 @@ import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; /** Reusable ore processing: joins nonempty c:ores/material and c:ingots/material item tags. */ -public final class OreToIngotGenerator implements MachineRecipeGenerator { +public class OreToIngotGenerator implements MachineRecipeGenerator { public static final int INPUT_COUNT = 1; public static final int OUTPUT_COUNT = 2; public static final int DEFAULT_TIME = 100; diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java index 5ef6e159..2f7c3585 100644 --- a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeGenerationContext.java @@ -17,4 +17,8 @@ public record RecipeGenerationContext( public RecipeGenerationContext { recipes = List.copyOf(recipes); } + + public RecipeGenerationContext withProperties(GeneratorProperties replacement) { + return new RecipeGenerationContext(recipeType, registries, tags, recipes, replacement); + } } diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeTransforms.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeTransforms.java new file mode 100644 index 00000000..ad6fc047 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/RecipeTransforms.java @@ -0,0 +1,44 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import java.util.*; + +import net.minecraft.core.HolderSet; +import net.minecraft.world.level.material.Fluid; + +import zone.moddev.mc.lib.machine.ingredient.MachineIngredient; +import zone.moddev.mc.lib.machine.recipe.MachineRecipe; + +/** Reusable transformations for a generator's output, before validation and publication. */ +public final class RecipeTransforms { + private RecipeTransforms() {} + + /** + * Replaces all FE inputs with one fluid cost, rounded up; keeps other inputs, outputs and time. + */ + public static MachineRecipe energyToFluid( + MachineRecipe recipe, String group, HolderSet fluids, long energyPerMillibucket) { + if (group == null || group.isBlank() || fluids.size() == 0 || energyPerMillibucket < 1) + throw new IllegalArgumentException( + "A fluid group, nonempty fluid set and positive conversion rate are required"); + var inputs = new LinkedHashMap>(); + long energy = 0; + for (var entry : recipe.inputs().entrySet()) { + var remaining = new ArrayList(); + for (var ingredient : entry.getValue()) { + if (ingredient instanceof MachineIngredient.EnergyPart part) { + if (part.amount() < 1) throw new IllegalArgumentException("Energy must be positive"); + energy = Math.addExact(energy, part.amount()); + } else remaining.add(ingredient); + } + if (!remaining.isEmpty()) inputs.put(entry.getKey(), remaining); + } + if (energy == 0) return recipe; + long amount = energy / energyPerMillibucket + (energy % energyPerMillibucket == 0 ? 0 : 1); + if (amount > Integer.MAX_VALUE) + throw new IllegalArgumentException("Converted fluid amount exceeds tank range"); + inputs + .computeIfAbsent(group, ignored -> new ArrayList<>()) + .add(new MachineIngredient.FluidPart(fluids, (int) amount)); + return new MachineRecipe(inputs, recipe.outputs(), recipe.time()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/SmeltingRecipeGenerator.java b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/SmeltingRecipeGenerator.java new file mode 100644 index 00000000..821584df --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/machine/recipe/generation/SmeltingRecipeGenerator.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.lib.machine.recipe.generation; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.SmeltingRecipe; + +import zone.moddev.mc.lib.machine.recipe.MachineRecipeBuilder; + +/** Adapts existing smelting recipes, preserving their ingredients, outputs and cooking times. */ +public class SmeltingRecipeGenerator implements MachineRecipeGenerator { + public static final long DEFAULT_ENERGY_PER_TICK = 20; + + @Override + public void generate(RecipeGenerationContext ctx, MachineRecipeOutput output) { + var p = ctx.properties(); + p.requireOnly("input-group", "output-group", "energy-group", "energy-per-tick"); + String input = p.string("input-group", "input"); + String result = p.string("output-group", "output"); + String power = p.string("energy-group", "energy"); + long rate = p.longValue("energy-per-tick", DEFAULT_ENERGY_PER_TICK, 1, Long.MAX_VALUE); + for (var holder : ctx.recipes()) { + if (!(holder.value() instanceof SmeltingRecipe recipe)) continue; + var product = recipe.getResultItem(ctx.registries()); + if (product.isEmpty() || recipe.getIngredients().size() != 1 || recipe.getCookingTime() < 1) + throw p.error("Invalid smelting recipe " + holder.id()); + long energy; + try { + energy = Math.multiplyExact(rate, recipe.getCookingTime()); + } catch (ArithmeticException ex) { + throw p.error("Energy cost overflows for " + holder.id()); + } + var id = + ResourceLocation.fromNamespaceAndPath( + ctx.recipeType().id().getNamespace(), + "generated/" + + ctx.recipeType().id().getPath() + + "/smelting/" + + holder.id().getNamespace() + + "/" + + holder.id().getPath()); + output.accept( + id, + MachineRecipeBuilder.recipe(ctx.recipeType()) + .inputItems(input, recipe.getIngredients().getFirst(), 1) + .inputEnergy(power, energy) + .outputItem(result, product.copy()) + .time(recipe.getCookingTime()) + .build()); + } + } +} diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index ebfda5db..1cd1c416 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -1,4 +1,10 @@ { + "screen.mmdlib.tank.empty": "Empty", + "screen.mmdlib.tank.contents": "%s mB of %s", + "screen.mmdlib.tank.accepts": "Accepts: %s", + "screen.mmdlib.tank.none": "None", + "screen.mmdlib.tank.container_accepts": "Accepts %s in %s", + "screen.mmdlib.tank.container_rejects": "Doesn't accept %s in %s", "screen.mmdlib.machine.redstone_waiting": "Redstone input does not permit processing", "screen.mmdlib.machine.processor_warning": "%s: %s", "screen.mmdlib.machine.stalled": "Needs power", diff --git a/src/main/resources/mmdlib/machine/base_machine.xml b/src/main/resources/mmdlib/machine/base_machine.xml new file mode 100644 index 00000000..ec051d83 --- /dev/null +++ b/src/main/resources/mmdlib/machine/base_machine.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/main/resources/mmdlib/machine/base_powered_machine.xml b/src/main/resources/mmdlib/machine/base_powered_machine.xml index 11c067e8..8b0b538f 100644 --- a/src/main/resources/mmdlib/machine/base_powered_machine.xml +++ b/src/main/resources/mmdlib/machine/base_powered_machine.xml @@ -1,13 +1,10 @@ - - + - - diff --git a/src/main/resources/mmdlib/schema/screen.xsd b/src/main/resources/mmdlib/schema/screen.xsd index 8516c63a..95872a73 100644 --- a/src/main/resources/mmdlib/schema/screen.xsd +++ b/src/main/resources/mmdlib/schema/screen.xsd @@ -51,7 +51,7 @@ #method (instance getter on the block entity) or fully.qualified.Class#method (static, taking the block entity). - + diff --git a/src/main/resources/mmdlib/screen/base_machine.xml b/src/main/resources/mmdlib/screen/base_machine.xml new file mode 100644 index 00000000..f54bc43d --- /dev/null +++ b/src/main/resources/mmdlib/screen/base_machine.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mmdlib/screen/base_powered_machine.xml b/src/main/resources/mmdlib/screen/base_powered_machine.xml index 2b83f61e..7bc3f929 100644 --- a/src/main/resources/mmdlib/screen/base_powered_machine.xml +++ b/src/main/resources/mmdlib/screen/base_powered_machine.xml @@ -1,21 +1,7 @@ - - - - - - - - - - - - - - - - - - + + + diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java index f86630b0..4f0f5bc1 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenFillGeneratorTest.java @@ -21,6 +21,43 @@ class ScreenFillGeneratorTest { @TempDir Path directory; + @Test + void multipleTankGaugesBindSeparateIndexesAndReplaceableFill() throws Exception { + Files.createDirectories(directory.resolve("machine")); + Files.writeString( + directory.resolve("machine/furnace.xml"), + Files.readString(Path.of("src/companion/resources/mmdlib/machine/simple_lava_furnace.xml")) + .replace(" MachineScreenGenerators.generate(machines.get(key), node)) + .get(id) + .screen(); + var highlights = MachineScreenGenerators.compileHighlights(machines.get(id), screen); + assertEquals(List.of("tank_0", "tank_1"), highlights.get("fuel")); + assertTrue(screen.find("energy").isEmpty()); + for (int index = 0; index < 2; index++) { + var tank = screen.find("tank_" + index).orElseThrow(); + assertEquals("#getTankFluid(" + index + ")", tank.common().value().orElseThrow().text()); + assertEquals("#getTankCapacity(" + index + ")", tank.common().max().orElseThrow().text()); + } + var layout = ScreenLayout.of(screen, TextMeasure.NONE); + var tank = layout.bounds(screen.find("tank_0").orElseThrow()); + var input = layout.bounds(screen.find("slot_0").orElseThrow()); + var output = layout.bounds(screen.find("slot_1").orElseThrow()); + assertEquals(7 + tank.x() - 6, input.x() + output.right(), 1); + assertTrue(layout.bounds(screen.find("tank_1").orElseThrow()).x() >= tank.right()); + } + private void write(String name, String attrs, String body) throws Exception { Files.createDirectories(directory.resolve("screen")); Files.writeString( @@ -141,9 +178,11 @@ void realSimpleCrusherGeneratesSlotsAndInheritsCommonScreen() { List.of(sources.getFirst()), Map.of(id, machines.get(id).screen()), (key, node) -> - List.of( - new Node( - "slot", Map.of("index", "99"), List.of(), node.source(), node.line()))); + node.attributes().get("generator").equals("mmdlib:tanks") + ? List.of() + : List.of( + new Node( + "slot", Map.of("index", "99"), List.of(), node.source(), node.line()))); assertThrows( IllegalArgumentException.class, () -> MachineScreenGenerators.validate(machines.get(id), invalid.get(id).screen())); diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java index 8a5bfa1f..171fa80f 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java @@ -355,7 +355,7 @@ void companionControlsSitAboveInventoryAtRightMargin() throws Exception { require( screen .source(screen.find("toggle_0").orElseThrow().common()) - .endsWith("base_powered_machine.xml")); + .endsWith("base_machine.xml")); } } @@ -366,7 +366,7 @@ void allCompanionScreensPlaceBatteryAtRightMargin() throws Exception { java.util.List.of("crusher", "transmuter", "dual_crusher", "creative_power", "logistics")) { var screen = screens.get(ResourceLocation.parse("mmdlib_companion:" + name)); var info = (Element.Popout) screen.find("info").orElseThrow(); - require(screen.source(info.common()).endsWith("base_powered_machine.xml")); + require(screen.source(info.common()).endsWith("base_machine.xml")); require(info.text().orElseThrow().key().equals("mmdlib_companion:" + name + ".info")); if (name.equals("creative_power") || name.equals("logistics")) require( 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 437cd58f..3528a0b6 100644 --- a/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java +++ b/src/test/java/zone/moddev/mc/lib/machine/MachineDefinitionsTest.java @@ -32,6 +32,32 @@ private String crusher() throws Exception { return Files.readString(Path.of("src/companion/resources/mmdlib/machine/simple_crusher.xml")); } + @Test + void lavaFurnaceInheritsGenericBaseAndExpandsTanksWithoutPower() throws Exception { + var xml = + Files.readString(Path.of("src/companion/resources/mmdlib/machine/simple_lava_furnace.xml")); + write(xml.replace(" t.id()).toList()); + assertEquals(List.of(0, 1), plan.fluids().groups().getFirst().members()); + assertEquals(8000, plan.fluids().tanks().getFirst().capacity()); + assertEquals( + ResourceLocation.parse("minecraft:lava"), plan.fluids().tanks().getFirst().fluid()); + for (var invalid : + List.of( + xml.replace("capacity=\"8000\"", "capacity=\"0\""), + xml.replace("fluid=\"minecraft:lava\"", "fluid=\"Invalid:lava\""), + xml.replace(" MachineDefinitions.load(sources()), invalid); + } + } + @Test void inheritsPowerAndExpandsStableGroupsAndCustomProperties() throws Exception { write( @@ -141,7 +167,9 @@ void checksumIncludesInheritedSettingsButNotPathsOrFormatting() throws Exception assertEquals( original, MachineDefinitions.load( - List.of(new SourceRoot("test", copied, getClass().getClassLoader()))) + List.of( + sources().getFirst(), + new SourceRoot("test", copied, getClass().getClassLoader()))) .get(id) .checksum()); write(child); @@ -209,7 +237,7 @@ void inheritedScreensPreserveEveryUnnamedSiblingInOrder() throws Exception { + " text=\"base_end\"/>"); Files.writeString(directory.resolve("machine/base.xml"), base); var plan = MachineDefinitions.load(sources()).get(ResourceLocation.parse("test:crusher")); - var children = plan.screen().children(); + var children = plan.screen().children().stream().filter(n -> n.name().equals("hbox")).toList(); assertEquals(4, children.size()); assertEquals( List.of("first", "second", "base_end", "third", "fourth", "fifth"), @@ -217,9 +245,18 @@ void inheritedScreensPreserveEveryUnnamedSiblingInOrder() throws Exception { .flatMap(n -> n.children().stream()) .map(n -> n.attributes().get("text")) .toList()); + var standaloneAttributes = new HashMap<>(plan.screen().attributes()); + standaloneAttributes.remove("extends"); var built = zone.moddev.mc.lib.gui.load.ScreenLoader.read( - Map.of(plan.id(), plan.screen()), + Map.of( + plan.id(), + new zone.moddev.mc.lib.gui.load.ScreenReader.Node( + "screen", + standaloneAttributes, + children, + plan.screen().source(), + plan.screen().line())), new zone.moddev.mc.lib.gui.WidgetTypes(), zone.moddev.mc.lib.gui.load.ScreenFillGenerator.UNAVAILABLE); assertEquals( @@ -277,7 +314,7 @@ void malformedValuesNameTheirSourceAttributeAndValue() throws Exception { .replace("", "")); Files.writeString( directory.resolve("machine/base.xml"), - Files.readString(Path.of("src/main/resources/mmdlib/machine/base_powered_machine.xml")) + Files.readString(Path.of("src/main/resources/mmdlib/machine/base_machine.xml")) .replace("hardness=\"3.5\"", "hardness=\"3,5\"")); var inheritedError = assertThrows(