From d82971ec6328689a3fc2c7f31a351b1ffaf5a8b7 Mon Sep 17 00:00:00 2001 From: AyaYumi Date: Sun, 25 Oct 2026 18:50:22 +0800 Subject: [PATCH 1/2] fix(1.20.1): preserve native recipes and pattern identity; clean temporary content --- .github/workflows/publish.yml | 24 +- CHANGELOG.md | 69 ------ README.md | 4 +- build.gradle | 2 - gradle.properties | 2 +- gradle/config-ui-check.gradle | 29 --- gradle/merge-game-tests.gradle | 28 --- .../tests/ApiCompatibilityGameTests.java | 200 --------------- .../useless/tests/ExMachinisClientProbe.java | 65 ----- .../tests/OmissionRegressionGameTests.java | 172 ------------- .../useless_mod/recipes/compat_reaction.json | 10 - .../useless/tests/ConfigUiClientCheck.java | 231 ------------------ .../compat/create/StaffLinkStressState.java | 9 - .../AdvancedAlloyFurnaceRecipeCategory.java | 10 +- .../compat/jei/OmniversalEmiSelection.java | 24 ++ .../OmniversalPatternJeiTransferHandler.java | 19 +- .../recipe/AlloyFurnaceRecipeCatalog.java | 10 +- .../adapters/RecipeAdapterCompatRegistry.java | 6 +- .../ReactionChamberRecipeAdapter.java | 41 +++- .../ae/ae2/InscriberRecipeAdapter.java | 72 ++---- .../create/CreateRecipeAdapterUtils.java | 9 + .../tconstruct/TConstructRecipeAdapter.java | 210 ++++++++++++++++ .../useless/core/config/ConfigManager.java | 4 + .../emi/EmiEncodePatternHandlerMixin.java | 47 ++++ .../assets/useless_mod/lang/en_us.json | 3 + .../assets/useless_mod/lang/zh_cn.json | 3 + .../assets/useless_mod/lang/zh_tw.json | 3 + .../useless_mod/textures/gui/addition.png | Bin 1231 -> 0 bytes .../gui/advanced_alloy_furnace_gui.png | Bin 3781 -> 0 bytes .../useless_mod/textures/icon/master.png | Bin 198 -> 0 bytes src/main/resources/useless_mod.mixins.json | 1 + .../create/CreateNativeRecipeTest.java | 16 ++ .../compat/jei/OmniversalEmiEncodingTest.java | 94 +++++++ .../jei/OmniversalEmiSelectionTest.java | 39 +++ .../compat/jei/UsefulIngotJeiTest.java | 171 +++++++++++++ .../ae/ae2/InscriberRecipeAdapterTest.java | 42 ++++ .../TConstructRecipeAdapterTest.java | 61 +++++ 37 files changed, 807 insertions(+), 923 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 gradle/config-ui-check.gradle delete mode 100644 src/compatGameTest/java/com/sorrowmist/useless/tests/ApiCompatibilityGameTests.java delete mode 100644 src/compatGameTest/java/com/sorrowmist/useless/tests/ExMachinisClientProbe.java delete mode 100644 src/compatGameTest/java/com/sorrowmist/useless/tests/OmissionRegressionGameTests.java delete mode 100644 src/compatGameTest/resources/data/useless_mod/recipes/compat_reaction.json delete mode 100644 src/configUiCheck/java/com/sorrowmist/useless/tests/ConfigUiClientCheck.java create mode 100644 src/main/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelection.java create mode 100644 src/main/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapter.java create mode 100644 src/main/java/com/sorrowmist/useless/mixin/emi/EmiEncodePatternHandlerMixin.java delete mode 100644 src/main/resources/assets/useless_mod/textures/gui/addition.png delete mode 100644 src/main/resources/assets/useless_mod/textures/gui/advanced_alloy_furnace_gui.png delete mode 100644 src/main/resources/assets/useless_mod/textures/icon/master.png create mode 100644 src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiEncodingTest.java create mode 100644 src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelectionTest.java create mode 100644 src/test/java/com/sorrowmist/useless/compat/jei/UsefulIngotJeiTest.java create mode 100644 src/test/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapterTest.java create mode 100644 src/test/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapterTest.java diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5ce238b9..cceff10d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,26 +56,8 @@ jobs: fi artifact_path="build/libs/useless_mod-${mod_version}.jar" - # 发布说明以 CHANGELOG.md 中该版本的章节为准;章节缺失时回退到 Release 正文, - # 再回退到自上一个标签以来的提交标题,避免平台侧出现空 changelog。 - # CHANGELOG.md 的版本标题与 mod_version 完全一致(1.20.1 线不带前缀),无需去前缀。 - cl_version="${mod_version}" - changelog="" - if [[ -f CHANGELOG.md ]]; then - changelog="$(awk -v ver="${cl_version}" ' - index($0, "## [" ver "]") == 1 { found = 1; next } - found && index($0, "## [") == 1 { exit } - found { lines[++n] = $0 } - END { - start = 1; while (start <= n && lines[start] ~ /^[[:space:]]*$/) start++ - end = n; while (end >= start && lines[end] ~ /^[[:space:]]*$/) end-- - for (i = start; i <= end; i++) print lines[i] - } - ' CHANGELOG.md)" - fi - if [[ -z "${changelog//[[:space:]]/}" ]]; then - changelog="${RELEASE_BODY}" - fi + # 使用 Release 正文;未填写时从提交记录生成平台要求的发布说明。 + changelog="${RELEASE_BODY}" if [[ -z "${changelog//[[:space:]]/}" ]]; then prev_tag="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)" if [[ -n "${prev_tag}" ]]; then @@ -97,7 +79,7 @@ jobs: echo "CHANGELOG_EOF" } >> "$GITHUB_OUTPUT" - - name: Setup JDK 21 + - name: Setup JDK 17 uses: actions/setup-java@v4 with: java-version: '17' diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a3be1a1a..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,69 +0,0 @@ -# 更新日志 - -本文件记录 UselessMod **Forge 1.20.1** 分支的版本变更,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), -每个条目附英文对照,分类固定为「新增 / 变更 / 修复 / 性能」四类。 -NeoForge 1.21.1 分支的更新记录见 `1.21` 分支的同名文件。 - -## [Unreleased] - -## [20.2.4.6] - -面向 Forge 1.20.1 的首次正式发布。本版本把 1.20.1 线自 `26.6.16` 起整体对齐到 1.21 线的 `2.4.6` 特性树, -补回此前只存在于 1.21 分支的功能,并针对 Forge API、原生配方与 AE2 15 重新适配。 -The first official release for Forge 1.20.1, bringing the 1.20.1 line from `26.6.16` fully in line with the `2.4.6` -feature tree of the 1.21 line, restoring features that previously existed only on 1.21 and re-adapting them to the -Forge API, native recipes and AE2 15. - -### Added / 新增 - -- 新增 `/uselessmod patterns repair [preview]` 命令,扫描 AE 网格上已通电的单方块万象炉与多方块 ME 样板总成,按当前配方目录重新匹配并重编码其中失效的万象样板,就地写回原槽位 - - Added the `/uselessmod patterns repair [preview]` command, which rescans powered single-block omniversal furnaces and multiblock ME pattern assemblies on the AE grid, re-matches and re-encodes their invalid omniversal patterns against the current recipe directory, and writes them back in place. -- 新增万象样板转换器,可在样板格式之间就地转换而无需重新编码 - - Added the Universal Pattern Converter, which converts patterns between formats in place without re-encoding. -- 温度调节器从塑料恒温器中独立为单独方块,按设定温度自发光并支持 Jade 显示 - - Split temperature control out of the Plastic Thermostat into a dedicated Temperature Regulator block, with temperature-driven emission and Jade display. -- 杀戮光环新增结算间隔配置项,可调节群体伤害的结算频率 - - Added a configuration option for the Kill Aura settlement interval, controlling how often area damage is applied. -- 生物捕捉写入刷怪蛋时保留目标完整 NBT,并可按配置精确丢弃指定键与数据附件命名空间 - - Creature capture now preserves the target's full NBT in the spawn egg, with configurable stripping of specific keys and attachment namespaces. -- 生物加速新增新生魔艺的德格米、探宝蟹与旋风精灵支持 - - Added Ars Nouveau Drygmy, Alakarkinos and Whirlisprig support to creature time acceleration. -- 新增建筑手杖功能与可选择的扳手模式 - - Added Building Staff functionality and selectable wrench modes. -- 新增血魔法与余烬的动态配方读取,配方随模组版本自动同步而无需等待本模组更新 - - Added dynamic recipe reading for Blood Magic and Embers, so recipes follow those mods' versions without waiting for a mod update. -- 新增 KubeJS 联动,包含高级合金炉配方 Schema 与配方组件 - - Added KubeJS integration, including the Advanced Alloy Furnace recipe schema and recipe components. -- 新增合金炉 BigInteger 配方 API 与 KubeJS 合金炉指南(见 `wiki/`) - - Added the Alloy Furnace BigInteger recipe API and a KubeJS Alloy Furnace guide (see `wiki/`). - -### Changed / 变更 - -- 连点模式不再限定造化杖,对其它物品同样生效 - - Auto-click mode is no longer limited to the Creation Staff and now works with other items. -- 智能翻倍在改写样板前先经 ECO 公开 API 判断,仅在同步确认作用域内且任务与材料向量匹配时原样保留确认计划 - - Smart doubling now consults the ECO public API first and preserves the confirmed plan verbatim only within the synchronous confirmation scope when the task and material vectors match. -- 重做紧凑型 ECO 单方块交互,接入单方块计算器,并为 C9/F9/L9 补齐任务持久化、样板预览、流体恢复与总览界面 - - Reworked the Compact ECO single-block interaction, wiring in the single-block calculators and adding task persistence, pattern preview, fluid restoration and an overview screen for the C9/F9/L9 compact machines. -- 合金炉界面在高 GUI 缩放下按可用空间缩放,避免槽位与数值溢出 - - The Alloy Furnace screen now scales to the available space at high GUI scales, preventing slot and value overflow. - -### Fixed / 修复 - -- 恢复 Forge 1.20.1 的工具标签兼容,造化杖的挖掘等级与工具判定重新正确 - - Restored Forge 1.20.1 tool tag compatibility, so the Creation Staff's mining level and tool checks work correctly again. -- 恢复批量快捷配方,此前在 1.20.1 上缺失的批量合成与快捷处理重新可用 - - Restored bulk quick recipes; bulk crafting and quick processing that were missing on 1.20.1 are available again. -- 安装 Configured 时恢复其配置界面入口,不再只能编辑配置文件 - - Restored the Configured config screen when Configured is installed, instead of falling back to editing config files. -- 按最低可用 API 设定 Thermal 与 CoFH 的版本下限,避免与过旧版本组合时静默失效 - - Set Thermal and CoFH version lower bounds to the minimum available API, avoiding silent failures when combined with older versions. -- 修复 Forge 1.20.1 上可选模组的联动判定,未安装对应模组时不再触发加载期异常 - - Fixed optional-mod integration detection on Forge 1.20.1; missing mods no longer cause load-time errors. - -### Performance / 性能 - -- 配方缓存键在构造时预先算好哈希,查缓存不再重复遍历三张输入 map - - Recipe cache keys precompute their hash at construction, so cache lookups no longer re-traverse the three input maps. -- 合金炉在输入未变时跳过重复的配方搜索,并在自动 IO 空转时按 1、2、4、8、16 tick 逐级退避、封顶 20 tick,本机内容变化或切换设置时立即恢复逐 tick 扫描 - - The Alloy Furnace skips repeated recipe searches while its inputs are unchanged, and its auto-IO backs off as 1, 2, 4, 8 and 16 ticks (capped at 20) while idle, returning to every-tick scanning immediately when contents change or a setting is toggled. diff --git a/README.md b/README.md index 459938e1..14c449f7 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ A Minecraft mod featuring various utilities and enhancements. ## Forge 1.20.1 -Version `20.2.4.7` targets Minecraft 1.20.1, Forge 47.3.10+, Java 17, +Version `20.2.4.9` targets Minecraft 1.20.1, Forge 47.3.10+, Java 17, AE2 15.4.8+, Mekanism 10.4.15+ (compiled against 10.4.16.80), Mekanism Extras 1.5.0+ and the Create 2 Mekanism: Sky Edition 2 integration profile. Build with `./gradlew build` (Windows: `gradlew.bat build`); Python 3 is used by -nine release-bytecode/outline regression checks. Dependencies resolve from public Maven repositories. +release-bytecode and outline regression checks. Dependencies resolve from public Maven repositories. The `portBridgeTest` suite also runs as part of `check` and is not shipped in the mod jar. Forge, Minecraft and AE2 are required. Every compiled mod integration declares a diff --git a/build.gradle b/build.gradle index b154ab98..600fda22 100644 --- a/build.gradle +++ b/build.gradle @@ -135,5 +135,3 @@ apply from: 'gradle/bytecode-contract-tests.gradle' apply from: 'gradle/merge-game-tests.gradle' apply from: 'gradle/bundled-compat-libraries.gradle' - -apply from: 'gradle/config-ui-check.gradle' diff --git a/gradle.properties b/gradle.properties index b4d4ee73..d6a42e3c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,6 @@ forge_version=47.4.10 loader_version_range=[47,) mod_id=useless_mod mod_name=Useless Mod -mod_version=20.2.4.7 +mod_version=20.2.4.9 mod_group_id=com.sorrowmist.useless mod_license=MIT diff --git a/gradle/config-ui-check.gradle b/gradle/config-ui-check.gradle deleted file mode 100644 index c41153b2..00000000 --- a/gradle/config-ui-check.gradle +++ /dev/null @@ -1,29 +0,0 @@ -// Isolated client checks; no test classes or optional GUI mod enter the release JAR. -if (!providers.gradleProperty('configUiCheck').isPresent()) return - -sourceSets { - configUiCheck { - compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath - runtimeClasspath += sourceSets.main.output + sourceSets.main.runtimeClasspath - } -} -dependencies { - modRuntimeOnly 'maven.modrinth:XxWD5pD3:jYgSnxye' - modRuntimeOnly 'maven.modrinth:Ck4E7v7R:i7Tp1AHw' - modRuntimeOnly 'io.github.llamalad7:mixinextras-forge:0.5.0' - if (!providers.gradleProperty('configUiWithoutConfigured').isPresent()) { - modRuntimeOnly 'curse.maven:configured-457570:5180900' - } -} -legacyForge { - mods { "${mod_id}" { sourceSet sourceSets.configUiCheck } } - runs { - configUiCheck { - client() - gameDirectory = file('run/config-ui-check') - systemProperty 'useless.configUiCheck', 'true' - systemProperty 'useless.configUiScreenshots', providers.gradleProperty('configUiScreenshots') - .getOrElse(file('run/config-ui-check/screenshots').absolutePath) - } - } -} diff --git a/gradle/merge-game-tests.gradle b/gradle/merge-game-tests.gradle index 90b0b9d9..81ff37b5 100644 --- a/gradle/merge-game-tests.gradle +++ b/gradle/merge-game-tests.gradle @@ -29,34 +29,6 @@ dependencies { modRuntimeOnly 'io.github.llamalad7:mixinextras-forge:0.5.0' } -if (providers.gradleProperty('compatGameTest').isPresent()) { - sourceSets.gameTest.java.srcDir 'src/compatGameTest/java' - sourceSets.gameTest.resources.srcDir 'src/compatGameTest/resources' - def stretcherRepo = providers.gradleProperty('stretcherRepo').get() - repositories { - flatDir { dirs "${stretcherRepo}/build/libs" } - } - dependencies { - modRuntimeOnly 'maven.modrinth:rxYaglEe:d83Wdhdn' - modRuntimeOnly 'curse.maven:applied-flux-965012:6755986' - modRuntimeOnly 'maven.modrinth:UhW5uCKw:eoUaDkZf' - modRuntimeOnly 'maven.modrinth:Ce6I4WUE:uxe1WQp4' - modRuntimeOnly 'maven.modrinth:IiATswDj:9n9p68Qq' - modRuntimeOnly 'maven.modrinth:qDJXZJTz:PQ3IlR98' - modRuntimeOnly 'maven.modrinth:8BmcQJ2H:aVW7Z5da' - modRuntimeOnly 'local.compat:useless_stretcher:20.1.6.0-compat.2' - gameTestCompileOnly files("${stretcherRepo}/build/devlibs/useless_stretcher-20.1.6.0-compat.2.jar") - } - if (providers.gradleProperty('compatClientProbe').isPresent()) { - repositories { flatDir { dirs providers.gradleProperty('packMods').get() } } - dependencies { - modRuntimeOnly 'maven.modrinth:u6dRKJwZ:TrKZlZ0b' - modRuntimeOnly 'pack.fixture:exmachinis-deorum:1.20.1-3.0.3' - } - legacyForge.runs.client.systemProperty 'useless.compatClientProbe', 'true' - } -} - legacyForge { mods { "${mod_id}" { sourceSet sourceSets.gameTest } } runs { diff --git a/src/compatGameTest/java/com/sorrowmist/useless/tests/ApiCompatibilityGameTests.java b/src/compatGameTest/java/com/sorrowmist/useless/tests/ApiCompatibilityGameTests.java deleted file mode 100644 index 101d796d..00000000 --- a/src/compatGameTest/java/com/sorrowmist/useless/tests/ApiCompatibilityGameTests.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.sorrowmist.useless.tests; - -import appeng.core.definitions.AEBlocks; -import appeng.blockentity.networking.WirelessAccessPointBlockEntity; -import appeng.blockentity.storage.DriveBlockEntity; -import com.mojang.authlib.GameProfile; -import com.sorrowmist.useless.UselessMod; -import com.sorrowmist.useless.compat.ae.AeDeviceLinker; -import com.sorrowmist.useless.compat.forge1201.component.ComponentAccess; -import com.sorrowmist.useless.compat.forge1201.recipe.RecipeManagerAccess; -import com.sorrowmist.useless.content.blockentities.AdvancedAlloyFurnaceBlockEntity; -import com.sorrowmist.useless.content.machines.advanced_alloy_furnace.layout.AdvancedAlloyFurnaceLayout; -import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeManager; -import com.sorrowmist.useless.content.recipe.adapters.ae.advancedae.ReactionChamberRecipeAdapter; -import com.sorrowmist.useless.content.recipe.adapters.mekanism.RecyclerRecipeAdapter; -import com.sorrowmist.useless.content.recipe.adapters.mekanism.mekanismmoremachine.AmbientGasCollectorRecipeAdapter; -import com.sorrowmist.useless.core.component.UComponents; -import com.sorrowmist.useless.event.EventHandler; -import com.sorrowmist.useless.init.ModBlocks; -import com.sorrowmist.useless.init.ModItems; -import com.sorrowmist.useless.stretcher.compat.StaffRadiationProtection; -import com.sorrowmist.useless.world.ae.AeConnectLinkSavedData; -import mekanism.common.capabilities.Capabilities; -import mekanism.common.lib.radiation.RadiationManager; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.core.GlobalPos; -import net.minecraft.gametest.framework.GameTest; -import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.world.InteractionHand; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; -import net.minecraft.world.level.GameType; -import net.minecraft.world.level.material.Fluids; -import net.minecraft.world.phys.BlockHitResult; -import net.minecraft.world.phys.Vec3; -import net.minecraftforge.common.util.FakePlayerFactory; -import net.minecraftforge.event.entity.player.PlayerInteractEvent; -import net.minecraftforge.fluids.FluidStack; -import net.minecraftforge.gametest.GameTestHolder; -import net.minecraftforge.gametest.PrefixGameTestTemplate; -import net.pedroksl.advanced_ae.recipes.ReactionChamberRecipe; - -import java.util.UUID; -import java.util.Map; - -@GameTestHolder(UselessMod.MODID) -@PrefixGameTestTemplate(false) -public final class ApiCompatibilityGameTests { - @GameTest(template = "furnace_empty", timeoutTicks = 400) - public static void reactionChamberConsumesExactItemsAndFluid(GameTestHelper helper) { - var level = helper.getLevel(); - var adapter = new ReactionChamberRecipeAdapter(); - helper.assertTrue(AlloyFurnaceRecipeManager.getInstance().getRegisteredAdapters().stream() - .anyMatch(ReactionChamberRecipeAdapter.class::isInstance), "Reaction adapter must register at startup"); - var holder = RecipeManagerAccess.getAllRecipesFor(level.getRecipeManager(), ReactionChamberRecipe.TYPE).stream() - .filter(r -> r.id().equals(new ResourceLocation("useless_mod", "compat_reaction"))) - .findFirst().orElseThrow(); - var recipe = adapter.convert(holder, level); - helper.assertTrue(recipe.energy() == 12000 && recipe.inputFluids().get(0).amount() == 500, - "Energy and fluid amount must survive the new native API"); - var pos = new BlockPos(2, 1, 2); - helper.setBlock(pos, ModBlocks.ADVANCED_ALLOY_FURNACE_BLOCK.get()); - var furnace = (AdvancedAlloyFurnaceBlockEntity) level.getBlockEntity(helper.absolutePos(pos)); - furnace.getItemHandler().setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 4)); - furnace.getItemHandler().setStackInSlot(AdvancedAlloyFurnaceLayout.MOLD_SLOT, adapter.getMoldItem()); - furnace.getInputFluidTank(0).setFluid(new FluidStack(Fluids.WATER, 499)); - furnace.setEnergy(1_000_000L); - helper.runAtTickTime(25, () -> { - helper.assertTrue(furnace.getItemHandler().getStackInSlot(0).getCount() == 4 - && furnace.getInputFluidTank(0).getFluidAmount() == 499, - "Insufficient fluid must not consume either duplicate item requirement"); - furnace.getInputFluidTank(0).setFluid(new FluidStack(Fluids.WATER, 500)); - furnace.setChanged(); - }); - helper.succeedWhen(() -> { - int diamonds = 0; - for (int slot = 9; slot < 18; slot++) { - var stack = furnace.getItemHandler().getStackInSlot(slot); - if (stack.is(Items.DIAMOND)) diamonds += stack.getCount(); - } - helper.assertTrue(diamonds == 3 && furnace.getItemHandler().getStackInSlot(0).isEmpty() - && furnace.getInputFluidTank(0).isEmpty(), "Must consume four ingots and 500 mB exactly once"); - helper.assertTrue(ItemStack.isSameItem(furnace.getItemHandler().getStackInSlot( - AdvancedAlloyFurnaceLayout.MOLD_SLOT), adapter.getMoldItem()), "Mold must remain"); - }); - } - - @GameTest(template = "furnace_empty") - public static void moreMachineAdaptersRegisterAndGenerateRecipes(GameTestHelper helper) { - var adapters = AlloyFurnaceRecipeManager.getInstance().getRegisteredAdapters(); - var recycler = adapters.stream().filter(RecyclerRecipeAdapter.class::isInstance) - .map(RecyclerRecipeAdapter.class::cast).findFirst().orElseThrow(); - var collector = adapters.stream().filter(AmbientGasCollectorRecipeAdapter.class::isInstance) - .map(AmbientGasCollectorRecipeAdapter.class::cast).findFirst().orElseThrow(); - var recipes = recycler.getGeneratedRecipes(helper.getLevel()); - helper.assertTrue(!recipes.isEmpty(), "Recycler must expose its native outputs"); - helper.assertTrue(recipes.stream().allMatch(r -> r.value().convertedRecipe().energy() > 0 - && r.value().convertedRecipe().inputFluids().get(0).amount() == 1000), - "Recycler must retain the water cost and FloatingLong energy cost"); - var gas = collector.getGeneratedRecipes(helper.getLevel()); - helper.assertTrue(gas.size() == 1 && gas.get(0).value().convertedRecipe().keyOutputs().size() == 1, - "Ambient collector must produce the native gas key through Applied Mekanistics"); - long water = gas.get(0).value().convertedRecipe().inputFluids().get(0).amount(); - helper.assertTrue(!collector.findMatchingRecipes(helper.getLevel(), Map.of(), - Map.of(new FluidStack(Fluids.WATER, 1), water), Map.of(), collector.getMoldItem()).isEmpty(), - "Generated gas recipes must also be available to actual machine lookup"); - helper.succeed(); - } - - @GameTest(template = "furnace_empty", timeoutTicks = 400) - public static void recyclerRunsInSingleBlockFurnaceWithOnlyWater(GameTestHelper helper) { - var adapter = new RecyclerRecipeAdapter(); - var outputs = adapter.getGeneratedRecipes(helper.getLevel()).stream() - .flatMap(r -> r.value().convertedRecipe().outputs().stream()).toList(); - var pos = new BlockPos(2, 1, 2); - helper.setBlock(pos, ModBlocks.ADVANCED_ALLOY_FURNACE_BLOCK.get()); - var furnace = (AdvancedAlloyFurnaceBlockEntity) helper.getLevel().getBlockEntity(helper.absolutePos(pos)); - furnace.getItemHandler().setStackInSlot(AdvancedAlloyFurnaceLayout.MOLD_SLOT, adapter.getMoldItem()); - furnace.getInputFluidTank(0).setFluid(new FluidStack(Fluids.WATER, 1000)); - furnace.setEnergy(1_000_000L); - helper.succeedWhen(() -> { - boolean produced = false; - for (int slot = 9; slot < 18; slot++) { - var output = furnace.getItemHandler().getStackInSlot(slot); - if (outputs.stream().anyMatch(expected -> ItemStack.matches(expected, output))) produced = true; - } - helper.assertTrue(produced && furnace.getInputFluidTank(0).isEmpty(), - "Recycler must consume exactly one bucket and produce a native output without item inputs"); - }); - } - - @GameTest(template = "furnace_empty") - public static void staffBlocksNewRadiationAndClearsDoseBeforeEffects(GameTestHelper helper) { - var player = FakePlayerFactory.get(helper.getLevel(), new GameProfile(UUID.randomUUID(), "RadiationTest")); - player.setGameMode(GameType.SURVIVAL); - player.setItemInHand(InteractionHand.MAIN_HAND, - new ItemStack(com.sorrowmist.useless.stretcher.init.ModItems.WONDROUS_STAFF.get())); - var dose = player.getCapability(Capabilities.RADIATION_ENTITY).orElseThrow(IllegalStateException::new); - StaffRadiationProtection.configure(player, false, false); - dose.set(RadiationManager.BASELINE); - RadiationManager.get().radiate(player, 100); - helper.assertTrue(dose.getRadiation() > RadiationManager.BASELINE, "Positive control must receive radiation"); - StaffRadiationProtection.configure(player, false, true); - RadiationManager.get().radiate(player, 100); - helper.assertTrue(dose.getRadiation() == RadiationManager.BASELINE, "Enabled staff must block new radiation"); - dose.set(100); - dose.update(player); - helper.assertTrue(dose.getRadiation() == RadiationManager.BASELINE, - "Native radiation update must clear an existing dose immediately, without waiting 20 ticks"); - player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); - helper.succeed(); - } - - @GameTest(template = "furnace_empty", timeoutTicks = 200) - public static void staffConnectsRestoresAndDisconnectsNativeAeMachine(GameTestHelper helper) { - var level = helper.getLevel(); - var power = new BlockPos(1, 1, 1); - var access = new BlockPos(2, 1, 1); - var machine = new BlockPos(6, 1, 1); - helper.setBlock(power, AEBlocks.CREATIVE_ENERGY_CELL.block()); - helper.setBlock(access, AEBlocks.WIRELESS_ACCESS_POINT.block()); - helper.setBlock(access.south(), AEBlocks.CREATIVE_ENERGY_CELL.block()); - helper.setBlock(machine, AEBlocks.DRIVE.block()); - helper.runAtTickTime(40, () -> { - var accessPos = helper.absolutePos(access); - var machinePos = helper.absolutePos(machine); - var player = FakePlayerFactory.get(level, new GameProfile(UUID.randomUUID(), "AeLinkTest")); - var tool = new ItemStack(com.sorrowmist.useless.stretcher.init.ModItems.WONDROUS_STAFF.get()); - player.setItemInHand(InteractionHand.MAIN_HAND, tool); - ComponentAccess.set(tool, UComponents.AeNetworkConnectComponent.get(), true); - ComponentAccess.set(tool, UComponents.WIRELESS_LINK_TARGET.get(), GlobalPos.of(level.dimension(), accessPos)); - var ap = (WirelessAccessPointBlockEntity) level.getBlockEntity(accessPos); - helper.assertTrue(ap.getMainNode().isOnline(), "Fixture access point must be online before connecting"); - var machineNode = ((DriveBlockEntity) level.getBlockEntity(machinePos)).getMainNode().getNode(); - helper.assertTrue(machineNode != null && !machineNode.isPowered(), - "Fixture machine must start outside a powered network"); - var event = new PlayerInteractEvent.RightClickBlock(player, InteractionHand.MAIN_HAND, machinePos, - new BlockHitResult(Vec3.atCenterOf(machinePos), Direction.UP, machinePos, false)); - EventHandler.onAeConnectInteract(event); - helper.assertTrue(event.isCanceled(), "Staff AE mode must consume the click instead of opening the GUI"); - var link = new AeConnectLinkSavedData.Link(level.dimension(), level.dimension(), accessPos, machinePos); - var data = AeConnectLinkSavedData.get(level.getServer()); - helper.assertTrue(data.contains(link), "Native AE machine must be linked and saved"); - var accessNode = ap.getMainNode().getNode(); - var connection = accessNode.getConnections().stream() - .filter(c -> !c.isInWorld() && c.getOtherSide(accessNode) == machineNode).findFirst().orElseThrow(); - connection.destroy(); - AeDeviceLinker.ensureLinks(level.getServer()); - helper.assertTrue(accessNode.getConnections().stream().anyMatch(c -> c.getOtherSide(accessNode) == machineNode), - "Saved links must restore using the same native host lookup"); - AeDeviceLinker.toggle(level, player, tool, machinePos); - helper.assertTrue(!data.contains(link) && accessNode.getConnections().stream() - .noneMatch(c -> c.getOtherSide(accessNode) == machineNode), "Disconnect must remove only the saved link"); - helper.assertTrue(AeDeviceLinker.isLinkTarget(level, machinePos), "Disconnected device must remain a valid host"); - helper.succeed(); - }); - } -} diff --git a/src/compatGameTest/java/com/sorrowmist/useless/tests/ExMachinisClientProbe.java b/src/compatGameTest/java/com/sorrowmist/useless/tests/ExMachinisClientProbe.java deleted file mode 100644 index 74c9de4c..00000000 --- a/src/compatGameTest/java/com/sorrowmist/useless/tests/ExMachinisClientProbe.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.sorrowmist.useless.tests; - -import com.sorrowmist.useless.UselessMod; -import mezz.jei.api.gui.drawable.IDrawable; -import mezz.jei.api.gui.drawable.IDrawableStatic; -import mezz.jei.api.helpers.IGuiHelper; -import net.minecraft.client.Minecraft; -import net.minecraft.resources.ResourceLocation; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.eventbus.api.SubscribeEvent; -import net.minecraftforge.fml.common.Mod; -import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; -import net.minecraftforge.event.TickEvent; - -import java.lang.reflect.Proxy; -import java.util.concurrent.atomic.AtomicReference; - -/** Runs the real third-party constructor under the client Mixin transformer, then exits. */ -@Mod.EventBusSubscriber(modid = UselessMod.MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) -public final class ExMachinisClientProbe { - private static volatile boolean finished; - @SubscribeEvent - public static void setup(FMLClientSetupEvent event) { - if (!Boolean.getBoolean("useless.compatClientProbe")) return; - event.enqueueWork(() -> { - try { - var background = new AtomicReference(); - var drawable = Proxy.newProxyInstance(IDrawableStatic.class.getClassLoader(), - new Class[]{IDrawableStatic.class}, (proxy, method, args) -> switch (method.getName()) { - case "getWidth" -> 82; - case "getHeight" -> 34; - default -> null; - }); - var helper = Proxy.newProxyInstance(IGuiHelper.class.getClassLoader(), - new Class[]{IGuiHelper.class}, (proxy, method, args) -> { - if (method.getName().equals("createDrawable")) background.set((ResourceLocation) args[0]); - if (method.getName().startsWith("createDrawable")) return drawable; - throw new AssertionError("Unexpected JEI call: " + method.getName()); - }); - var category = Class.forName("net.permutated.exmachinis.compat.jei.category.CompactingCategory") - .getConstructor(IGuiHelper.class).newInstance(helper); - var expected = new ResourceLocation("useless_mod", "textures/gui/exmachinis_recipe.png"); - if (!expected.equals(background.get())) throw new AssertionError("Mixin did not redirect the removed field"); - var rendered = (IDrawable) category.getClass().getMethod("getBackground").invoke(category); - if (rendered.getWidth() != 82 || rendered.getHeight() != 34) throw new AssertionError("Incorrect background size"); - try (var texture = ExMachinisClientProbe.class.getResourceAsStream( - "/assets/useless_mod/textures/gui/exmachinis_recipe.png")) { - if (texture == null || texture.readAllBytes().length < 100) throw new AssertionError("Missing background texture"); - } - System.out.println("COMPAT_CLIENT_PROBE_OK ExMachinis constructor + JEI 15.74 + redirected texture"); - finished = true; - } catch (ReflectiveOperationException | java.io.IOException failure) { - throw new IllegalStateException("Ex Machinis compatibility probe failed", failure); - } - }); - } - - @Mod.EventBusSubscriber(modid = UselessMod.MODID, value = Dist.CLIENT) - public static final class ExitAfterStartup { - @SubscribeEvent - public static void tick(TickEvent.ClientTickEvent event) { - if (finished && event.phase == TickEvent.Phase.END) Minecraft.getInstance().stop(); - } - } -} diff --git a/src/compatGameTest/java/com/sorrowmist/useless/tests/OmissionRegressionGameTests.java b/src/compatGameTest/java/com/sorrowmist/useless/tests/OmissionRegressionGameTests.java deleted file mode 100644 index 44ce6a27..00000000 --- a/src/compatGameTest/java/com/sorrowmist/useless/tests/OmissionRegressionGameTests.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.sorrowmist.useless.tests; - -import com.sorrowmist.useless.UselessMod; -import com.sorrowmist.useless.compat.forge1201.component.ComponentAccess; -import com.sorrowmist.useless.compat.forge1201.recipe.RecipeHolder; -import com.sorrowmist.useless.content.items.BeefToolVariants; -import com.sorrowmist.useless.content.recipe.AdvancedAlloyFurnaceRecipe; -import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeManager; -import com.sorrowmist.useless.content.recipe.adapters.avaritia.*; -import com.sorrowmist.useless.core.component.UComponents; -import com.sorrowmist.useless.init.ModItems; -import committee.nova.mods.avaritia.common.crafting.recipe.ExtremeSmithingRecipe; -import committee.nova.mods.avaritia.init.registry.ModRecipeTypes; -import net.minecraft.core.registries.Registries; -import net.minecraft.gametest.framework.GameTest; -import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.tags.TagKey; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; -import net.minecraft.world.item.crafting.Ingredient; -import net.minecraftforge.gametest.GameTestHolder; -import net.minecraftforge.gametest.PrefixGameTestTemplate; - -import java.util.List; - -@GameTestHolder(UselessMod.MODID) -@PrefixGameTestTemplate(false) -public final class OmissionRegressionGameTests { - @GameTest(template = "furnace_empty") - public static void thermalUpgradesAndLegacyShortcutsLoad(GameTestHelper helper) { - var manager = helper.getLevel().getRecipeManager(); - for (int tier = 1; tier <= 9; tier++) { - helper.assertTrue(manager.byKey(UselessMod.id("useless_integral_component_tier_" + tier)).isPresent(), - "Missing integral component recipe " + tier); - } - for (int tier = 1; tier <= 3; tier++) { - helper.assertTrue(manager.byKey(UselessMod.id("augment_parallel_" + tier)).isPresent(), - "Missing parallel augment recipe " + tier); - } - for (String id : List.of("machine/smelter/thermal/plate/iron_plate", "machine/smelter/ae2/common/fluix_crystal")) { - var recipe = (AdvancedAlloyFurnaceRecipe) manager.byKey(UselessMod.id(id)).orElseThrow(); - helper.assertTrue(recipe.outputs().get(0).getCount() == 64 && recipe.catalystUses() == 1, - "Legacy batch and catalyst costs must survive the codec conversion"); - if (id.endsWith("fluix_crystal")) { - helper.assertTrue(recipe.inputFluids().get(0).amount() == 16000, "Fluix must consume 16 buckets"); - } else { - helper.assertTrue(recipe.energy() == 64000, "Iron plate energy must remain 64000"); - } - } - int checked = 0; - var resources = helper.getLevel().getServer().getResourceManager().listResources("recipes/machine/smelter", - id -> id.getNamespace().equals(UselessMod.MODID) - && (id.getPath().startsWith("recipes/machine/smelter/thermal/") - || id.getPath().startsWith("recipes/machine/smelter/ae2/"))); - for (var entry : resources.entrySet()) { - try (var reader = entry.getValue().openAsReader()) { - var json = com.google.gson.JsonParser.parseReader(reader).getAsJsonObject(); - var conditional = json.getAsJsonArray("recipes").get(0).getAsJsonObject(); - boolean enabled = true; - for (var element : conditional.getAsJsonArray("conditions")) { - var condition = element.getAsJsonObject(); - if (condition.get("type").getAsString().equals("forge:mod_loaded")) { - enabled &= net.minecraftforge.fml.ModList.get().isLoaded(condition.get("modid").getAsString()); - } else if (condition.get("type").getAsString().equals("forge:item_exists")) { - enabled &= net.minecraftforge.registries.ForgeRegistries.ITEMS.containsKey( - new ResourceLocation(condition.get("item").getAsString())); - } else { - throw new AssertionError("Unexpected legacy recipe condition: " + condition); - } - } - var path = entry.getKey().getPath(); - var id = UselessMod.id(path.substring("recipes/".length(), path.length() - ".json".length())); - var loaded = manager.byKey(id); - helper.assertTrue(loaded.isPresent() == enabled, "Conditional loading mismatch: " + id); - if (enabled) { - var recipe = (AdvancedAlloyFurnaceRecipe) loaded.orElseThrow(); - helper.assertTrue(!recipe.outputs().isEmpty() || !recipe.outputFluids().isEmpty(), "Empty output: " + id); - helper.assertTrue(recipe.catalystUses() == 1 && !recipe.mold().isEmpty(), "Lost mold/catalyst: " + id); - } - checked++; - } catch (java.io.IOException exception) { - throw new AssertionError(exception); - } - } - helper.assertTrue(checked == 59, "All 59 legacy shortcuts must be checked, got " + checked); - helper.succeed(); - } - - @GameTest(template = "furnace_empty") - public static void nativeAvaritiaRecipesRegisterAndConvert(GameTestHelper helper) { - var adapters = AlloyFurnaceRecipeManager.getInstance().getRegisteredAdapters(); - for (Class type : List.of(ReAvaritiaTableRecipeAdapter.class, ReAvaritiaExtremeSmithingRecipeAdapter.class, - ReAvaritiaCompressorRecipeAdapter.class, ReAvaritiaCollectorRecipeAdapter.class)) { - helper.assertTrue(adapters.stream().anyMatch(type::isInstance), "Missing native adapter " + type.getSimpleName()); - } - var tables = helper.getLevel().getRecipeManager().getAllRecipesFor(ModRecipeTypes.CRAFTING_TABLE_RECIPE.get()); - int converted = 0; - var tableAdapter = new ReAvaritiaTableRecipeAdapter(); - for (var recipe : tables) { - if (!tableAdapter.convertAll(RecipeHolder.of(recipe), helper.getLevel()).isEmpty()) converted++; - } - helper.assertTrue(converted > 0, "Native tier table recipes must actually convert"); - var compressor = new ReAvaritiaCompressorRecipeAdapter(); - var compressions = helper.getLevel().getRecipeManager().getAllRecipesFor(ModRecipeTypes.COMPRESSOR_RECIPE.get()); - helper.assertTrue(!compressions.isEmpty(), "Native compressor recipes must load"); - helper.assertTrue(compressor.convertAll(RecipeHolder.of(compressions.get(0)), helper.getLevel()).size() == 4, - "All four native compressor tiers must convert"); - helper.assertTrue(new ReAvaritiaCollectorRecipeAdapter().getGeneratedRecipes(helper.getLevel()).size() == 4, - "All four native collector tiers must generate recipes"); - helper.succeed(); - } - - @GameTest(template = "furnace_empty") - public static void tierTableKeepsDifferentNbtAndCraftingRemainders(GameTestHelper helper) { - var first = new ItemStack(Items.PAPER); - first.getOrCreateTag().putString("singularity", "first"); - var second = first.copy(); - second.getOrCreateTag().putString("singularity", "second"); - var slots = net.minecraft.core.NonNullList.create(); - slots.add(net.minecraftforge.common.crafting.StrictNBTIngredient.of(first)); - slots.add(net.minecraftforge.common.crafting.StrictNBTIngredient.of(second)); - slots.add(net.minecraftforge.common.crafting.StrictNBTIngredient.of(first)); - slots.add(Ingredient.of(Items.WATER_BUCKET)); - var source = new committee.nova.mods.avaritia.common.crafting.recipe.ShapedTableCraftingRecipe( - UselessMod.id("test_table_nbt"), 2, 2, slots, new ItemStack(Items.DIAMOND), 1, false); - var converted = new ReAvaritiaTableRecipeAdapter().convertAll(RecipeHolder.of(source), helper.getLevel()); - helper.assertTrue(converted.size() == 1, "Native NBT table recipe must convert"); - var recipe = converted.get(0); - helper.assertTrue(recipe.inputs().size() == 3, "Different NBT must not collapse into one requirement"); - helper.assertTrue(recipe.inputs().stream().anyMatch(i -> i.count() == 2 && i.ingredient().test(first) - && !i.ingredient().test(second)), "Repeated exact stacks must aggregate without losing NBT"); - helper.assertTrue(recipe.outputs().stream().anyMatch(s -> s.is(Items.BUCKET)), "Native bucket remainder must return"); - helper.succeed(); - } - - @GameTest(template = "furnace_empty") - public static void extremeSmithingPreservesBaseNbtAndRequiresThreeAdditions(GameTestHelper helper) { - var source = new ExtremeSmithingRecipe(UselessMod.id("test_smithing"), Ingredient.of(Items.PAPER), - Ingredient.of(Items.IRON_SWORD), Ingredient.of(Items.DIAMOND), new ItemStack(Items.DIAMOND_SWORD)); - var base = new ItemStack(Items.IRON_SWORD); - base.getOrCreateTag().putString("owner", "retained"); - var adapter = new ReAvaritiaExtremeSmithingRecipeAdapter(); - var inputs = List.of(new ItemStack(Items.PAPER), base, new ItemStack(Items.DIAMOND, 3)); - var result = adapter.convertAll(RecipeHolder.of(source), helper.getLevel(), inputs); - helper.assertTrue(result.size() == 1, "Five native slots must convert"); - helper.assertTrue(result.get(0).inputs().size() == 5, "Smithing must require all three addition slots"); - helper.assertTrue("retained".equals(result.get(0).outputs().get(0).getTag().getString("owner")), - "Native assemble must copy base NBT"); - helper.assertTrue(adapter.convertAll(RecipeHolder.of(source), helper.getLevel(), - List.of(new ItemStack(Items.PAPER), base, new ItemStack(Items.DIAMOND, 2))).isEmpty(), - "Two additions cannot satisfy three slots"); - helper.assertTrue(base.getCount() == 1 && inputs.get(2).getCount() == 3, "Conversion cannot mutate inputs"); - helper.succeed(); - } - - @GameTest(template = "furnace_empty") - public static void forgeWrenchAliasesFollowBothToolsSwitches(GameTestHelper helper) { - var beef = new ItemStack(ModItems.ENDLESS_BEAF_ITEM.get()); - var disabled = BeefToolVariants.withWrenchTag(beef, false); - var staff = new ItemStack(com.sorrowmist.useless.stretcher.init.ModItems.WONDROUS_STAFF.get()); - for (String id : List.of("forge:tools/wrench", "forge:tools/wrenches", "forge:wrenches", "mekanism:configurators")) { - var tag = TagKey.create(Registries.ITEM, new ResourceLocation(id)); - helper.assertTrue(beef.is(tag) && !disabled.is(tag), "Core wrench aliases must honor G toggle: " + id); - ComponentAccess.set(staff, UComponents.WrenchTagEnabledComponent.get(), true); - helper.assertTrue(staff.is(tag), "Enabled staff must match " + id); - ComponentAccess.set(staff, UComponents.WrenchTagEnabledComponent.get(), false); - helper.assertTrue(!staff.is(tag), "Disabled staff must reject " + id); - } - helper.succeed(); - } -} diff --git a/src/compatGameTest/resources/data/useless_mod/recipes/compat_reaction.json b/src/compatGameTest/resources/data/useless_mod/recipes/compat_reaction.json deleted file mode 100644 index 69a05b8a..00000000 --- a/src/compatGameTest/resources/data/useless_mod/recipes/compat_reaction.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "advanced_ae:reaction", - "energy": 12000, - "fluid": {"fluidStack": {"Amount": 500, "FluidName": "minecraft:water"}}, - "input_items": [ - {"amount": 2, "ingredient": {"item": "minecraft:iron_ingot"}}, - {"amount": 2, "ingredient": {"item": "minecraft:iron_ingot"}} - ], - "output": {"#": 3, "#c": "ae2:i", "id": "minecraft:diamond"} -} diff --git a/src/configUiCheck/java/com/sorrowmist/useless/tests/ConfigUiClientCheck.java b/src/configUiCheck/java/com/sorrowmist/useless/tests/ConfigUiClientCheck.java deleted file mode 100644 index 14bd055f..00000000 --- a/src/configUiCheck/java/com/sorrowmist/useless/tests/ConfigUiClientCheck.java +++ /dev/null @@ -1,231 +0,0 @@ -package com.sorrowmist.useless.tests; - -import com.sorrowmist.useless.UselessMod; -import com.sorrowmist.useless.client.gui.PortConfigScreen; -import com.sorrowmist.useless.core.config.ConfigManager; -import net.minecraft.client.Minecraft; -import net.minecraft.client.Screenshot; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.ResourceLocation; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.client.ConfigScreenHandler.ConfigScreenFactory; -import net.minecraftforge.event.TickEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; -import net.minecraftforge.fml.ModList; -import net.minecraftforge.fml.common.Mod; - -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -/** Opens the real registered factory and native Configured widgets in an isolated client. */ -@Mod.EventBusSubscriber(modid = UselessMod.MODID, value = Dist.CLIENT) -public final class ConfigUiClientCheck { - private static final ResourceLocation BACKGROUND = new ResourceLocation("minecraft", "textures/block/stone.png"); - private static int ticks; - private static int step; - private static boolean languageLoaded; - private static Screen parent; - private static Screen selection; - private static Screen common; - private static Object config; - private static Object root; - private static String query; - - @SubscribeEvent - public static void tick(TickEvent.ClientTickEvent event) { - if (!Boolean.getBoolean("useless.configUiCheck") || event.phase != TickEvent.Phase.END) return; - var mc = Minecraft.getInstance(); - if (mc.getOverlay() != null || ++ticks % 10 != 0) return; - if (step == 0 && !(mc.screen instanceof TitleScreen)) return; - try { - if (!languageLoaded) { - languageLoaded = true; - mc.options.languageCode = "zh_cn"; - mc.getLanguageManager().setSelected("zh_cn"); - mc.reloadResourcePacks(); - return; - } - if (step == 0) { - parent = mc.screen; - var factory = ModList.get().getModContainerById(UselessMod.MODID).orElseThrow() - .getCustomExtension(ConfigScreenFactory.class).orElseThrow(); - selection = factory.screenFunction().apply(mc, parent); - if (!ModList.get().isLoaded("configured")) { - check(selection instanceof PortConfigScreen, "Missing standalone fallback factory"); - mc.setScreen(selection); - step = 100; - return; - } - check(selection.getClass().getName().startsWith("com.mrcrayfish.configured."), - "Installed Configured must own the mod-list config factory"); - mc.setScreen(selection); - } else if (step == 1) { - capture("01-config-selection"); - selectConfig("-common.toml"); - common = screen(root); - mc.setScreen(common); - } else if (step == 2) { - capture("02-config-categories"); - check(!entries(common).isEmpty(), "Categories must be generated from the registered Forge spec"); - common = screen(folder(root, Boolean.class)); - mc.setScreen(common); - query = invoke(entry(common, "BooleanItem"), "getLabel").toString(); - ((EditBox) field(common, "searchTextField")).setValue(query); - } else if (step == 3) { - capture("03-config-search"); - var results = ((net.minecraft.client.gui.components.AbstractSelectionList) field(common, "list")).children(); - check(!results.isEmpty() && results.size() <= entries(common).size(), "Search must return matching entries"); - ((EditBox) field(common, "searchTextField")).setValue(""); - var booleanScreen = screen(folder(root, Boolean.class)); - mc.setScreen(booleanScreen); - var row = entry(booleanScreen, "BooleanItem"); - Object holder = field(row, "holder"); - Object before = invoke(holder, "get"); - ((Button) field(row, "button")).onPress(); - check(!before.equals(invoke(holder, "get")), "Boolean control must toggle its draft value"); - ((Button) field(row, "resetButton")).onPress(); - check(invoke(holder, "getDefault").equals(invoke(holder, "get")), "Per-value reset must restore defaults"); - } else if (step == 4) { - capture("04-boolean-controls"); - mc.setScreen(selection); - selectConfig("-client.toml"); - var numeric = screen(folder(root, Number.class)); - mc.setScreen(numeric); - var row = entries(numeric).stream().filter(v -> { - try { return invoke(field(v, "holder"), "get") instanceof Number; } - catch (RuntimeException ignored) { return false; } - }).findFirst().orElseThrow(); - Object holder = field(row, "holder"); - Object before = invoke(holder, "get"); - var editor = (EditBox) field(row, "textField"); - editor.setValue("not-a-number"); - check(before.equals(invoke(holder, "get")), "Invalid input must not replace the numeric draft"); - editor.setValue(before.toString()); - } else if (step == 5) { - capture("05-number-controls"); - mc.setScreen(selection); - // Only this test process: provide world-config defaults without opening a user world. - ConfigManager.SERVER_SPEC.setConfig(com.electronwill.nightconfig.core.CommentedConfig.inMemory()); - selectConfig("-server.toml"); - var lists = screen(folder(root, List.class)); - mc.setScreen(lists); - ((Button) field(entry(lists, "ListItem"), "button")).onPress(); - check(mc.screen.getClass().getSimpleName().equals("EditListScreen"), "List control must open a list editor"); - } else if (step == 6) { - capture("06-list-editor"); - mc.setScreen(selection); - selectConfig("-common.toml"); - common = screen(root); - mc.setScreen(common); - ((Button) field(common, "restoreButton")).onPress(); - check(mc.screen.getClass().getSimpleName().contains("Confirmation"), "Restore-all must require confirmation"); - } else if (step == 7) { - capture("07-restore-confirmation"); - mc.setScreen(common); - ((Button) field(entry(common, "FolderItem"), "button")).onPress(); - var edits = mc.screen; - ((Button) field(entry(edits, "BooleanItem"), "button")).onPress(); - back(edits); - check(mc.screen == common, "Category navigation must return to its root without losing the draft"); - back(common); - check(mc.screen.getClass().getSimpleName().contains("Confirmation"), "Closing unsaved edits must show confirmation"); - } else if (step == 8) { - capture("08-unsaved-confirmation"); - System.out.println("CONFIG_UI_CHECK_OK configured factory, categories, search, boolean/reset, numeric validation, lists, confirmations"); - mc.stop(); - } else if (step == 100) { - capture("09-standalone-fallback"); - System.out.println("CONFIG_UI_CHECK_OK standalone fallback without Configured"); - mc.stop(); - } - step++; - } catch (Throwable failure) { - throw new IllegalStateException("Config UI client check failed at step " + step, failure); - } - } - - private static Object invoke(Object object, String name) { - try { - var method = object.getClass().getMethod(name); - method.setAccessible(true); - return method.invoke(object); - } catch (ReflectiveOperationException failure) { - throw new IllegalStateException(name, failure); - } - } - - private static Object field(Object object, String name) { - for (Class type = object.getClass(); type != null; type = type.getSuperclass()) { - try { - Field field = type.getDeclaredField(name); - field.setAccessible(true); - return field.get(object); - } catch (NoSuchFieldException ignored) { - } catch (IllegalAccessException failure) { - throw new IllegalStateException(name, failure); - } - } - throw new IllegalStateException("Missing UI field " + name); - } - - private static List entries(Object screen) { return (List) field(screen, "entries"); } - - private static void back(Screen screen) { - screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast) - .filter(b -> b.getY() >= screen.height - 35) - .max(java.util.Comparator.comparingInt(Button::getX)).orElseThrow().onPress(); - } - - private static void selectConfig(String suffix) { - var configs = (Map) field(selection, "configMap"); - config = configs.values().stream().flatMap(v -> ((Collection) v).stream()) - .filter(v -> invoke(v, "getFileName").toString().endsWith(suffix)).findFirst().orElseThrow(); - root = invoke(config, "getRoot"); - } - - private static Object entry(Object screen, String kind) { - return entries(screen).stream().filter(v -> v.getClass().getSimpleName().equals(kind)).findFirst().orElseThrow(); - } - - private static Object folder(Object entry, Class valueType) { - for (Object child : (List) invoke(entry, "getChildren")) { - if ((boolean) invoke(child, "isLeaf") && valueType.isInstance(invoke(invoke(child, "getValue"), "get"))) return entry; - } - for (Object child : (List) invoke(entry, "getChildren")) { - if (!(boolean) invoke(child, "isLeaf")) { - Object found = folder(child, valueType); - if (found != null) return found; - } - } - return null; - } - - private static Screen screen(Object folder) throws ReflectiveOperationException { - check(folder != null, "Expected typed configuration category"); - var constructor = Class.forName("com.mrcrayfish.configured.client.screen.ConfigScreen").getDeclaredConstructor( - Screen.class, Component.class, Class.forName("com.mrcrayfish.configured.api.IModConfig"), - ResourceLocation.class, Class.forName("com.mrcrayfish.configured.api.IConfigEntry")); - constructor.setAccessible(true); - return (Screen) constructor.newInstance(selection, Component.literal("Useless Mod"), config, BACKGROUND, folder); - } - - private static void capture(String name) throws java.io.IOException { - var directory = Path.of(System.getProperty("useless.configUiScreenshots")); - Files.createDirectories(directory); - try (var image = Screenshot.takeScreenshot(Minecraft.getInstance().getMainRenderTarget())) { - image.writeToFile(directory.resolve(name + ".png")); - } - } - - private static void check(boolean valid, String message) { - if (!valid) throw new AssertionError(message); - } -} diff --git a/src/main/java/com/sorrowmist/useless/compat/create/StaffLinkStressState.java b/src/main/java/com/sorrowmist/useless/compat/create/StaffLinkStressState.java index 7c0b914a..d4af567c 100644 --- a/src/main/java/com/sorrowmist/useless/compat/create/StaffLinkStressState.java +++ b/src/main/java/com/sorrowmist/useless/compat/create/StaffLinkStressState.java @@ -259,15 +259,6 @@ public void applyRoute(MinecraftServer server, UUID networkId, int routeIndex, capacitySum = INFINITE_POWER; } - // 诊断:把每个输入网络的真实构成打出来(每 20 tick 一行)。 - // - // Create 6.0.x 的应力口径和直觉不一样,出问题时光看汇总数根本定位不到: - // · `calculateStress()` 遍历 `members` 时**不跳过动力源**,而 `add()` 会把所有方块 - // (含源)都放进 `members`,取值是**加入时缓存的** `calculateStressApplied()`; - // · `calculateCapacity()` 用 `sources` 里缓存的容量 × **`getGeneratedSpeed()`**; - // · 两者都还要加上 `unloadedStress` / `unloadedCapacity`(区块未加载的成员残值)。 - // 所以「谁贡献了多少」必须逐块打出来才能对上账。 - // 配置区状态行用整条线路汇总的三个数。 // // 玩家想知道的是「这条无线链路总共能借出多少」。四台风车轴承各自成网时,只看单个锚点 diff --git a/src/main/java/com/sorrowmist/useless/compat/jei/AdvancedAlloyFurnaceRecipeCategory.java b/src/main/java/com/sorrowmist/useless/compat/jei/AdvancedAlloyFurnaceRecipeCategory.java index e695c670..70901159 100644 --- a/src/main/java/com/sorrowmist/useless/compat/jei/AdvancedAlloyFurnaceRecipeCategory.java +++ b/src/main/java/com/sorrowmist/useless/compat/jei/AdvancedAlloyFurnaceRecipeCategory.java @@ -12,6 +12,7 @@ import com.sorrowmist.useless.init.ModTags; import com.sorrowmist.useless.core.config.AlloyFurnaceTierRules; import mezz.jei.api.constants.VanillaTypes; +import mezz.jei.api.forge.ForgeTypes; import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; import mezz.jei.api.gui.builder.ITooltipBuilder; import mezz.jei.api.gui.drawable.IDrawable; @@ -356,12 +357,15 @@ public void setRecipe(@NotNull IRecipeLayoutBuilder builder, IRecipeSlotBuilder slot = builder.addSlot(RecipeIngredientRole.INPUT, x, FLUID_INPUT_Y) .setFluidRenderer(displayAmount, false, fluidWidth, FLUID_INPUT_HEIGHT); + // JEI 15.74 redeclares addFluidStack with a covariant IRecipeSlotBuilder return; + // calling them breaks linkage on 15.48. The typed ingredient API works on both + // and retains the Forge stack's NBT instead of reconstructing a bare fluid. if (candidates.length == 0) { - slot.addFluidStack(fluid.getFluid(), displayAmount); + slot.addIngredient(ForgeTypes.FLUID_STACK, new FluidStack(fluid, displayAmount)); } else { for (FluidStack candidate : candidates) { if (candidate != null && !candidate.isEmpty()) { - slot.addFluidStack(candidate.getFluid(), displayAmount); + slot.addIngredient(ForgeTypes.FLUID_STACK, new FluidStack(candidate, displayAmount)); } } } @@ -384,7 +388,7 @@ public void setRecipe(@NotNull IRecipeLayoutBuilder builder, builder.addSlot(RecipeIngredientRole.OUTPUT, x, FLUID_OUTPUT_Y) .setFluidRenderer(amount, false, fluidWidth, FLUID_OUTPUT_HEIGHT) - .addFluidStack(fluid.getFluid(), amount) + .addIngredient(ForgeTypes.FLUID_STACK, fluid.copy()) .addRichTooltipCallback((slot, tooltip) -> { tooltip.add(Component.translatable("jei.useless_mod.tooltip.amount.mb", formatCount(amount)).withStyle(ChatFormatting.GRAY)); addRecipeIdTooltip(tooltip, recipe.id()); diff --git a/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelection.java b/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelection.java new file mode 100644 index 00000000..a3d77e07 --- /dev/null +++ b/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelection.java @@ -0,0 +1,24 @@ +package com.sorrowmist.useless.compat.jei; + +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeCatalog; +import dev.emi.emi.api.recipe.EmiRecipe; +import dev.emi.emi.jemi.JemiRecipe; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +/** Retains the exact JEI selection when AE2's native EMI encoder wins handler selection. */ +public final class OmniversalEmiSelection { + private static final ResourceLocation CATEGORY = new ResourceLocation("useless_mod", "advanced_alloy_furnace"); + + private OmniversalEmiSelection() { } + + @Nullable + public static AlloyFurnaceRecipeCatalog.Entry recipe(EmiRecipe recipe) { + // Do not infer a mold from ordinary machine pages or ambiguous input/output matches. + // JEMI retains the selected catalog entry, including its source and fingerprint. + if (recipe == null || recipe.getCategory() == null + || !CATEGORY.equals(recipe.getCategory().getId())) return null; + return recipe instanceof JemiRecipe imported + && imported.recipe instanceof AlloyFurnaceRecipeCatalog.Entry entry ? entry : null; + } +} diff --git a/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalPatternJeiTransferHandler.java b/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalPatternJeiTransferHandler.java index f40012fd..1140821d 100644 --- a/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalPatternJeiTransferHandler.java +++ b/src/main/java/com/sorrowmist/useless/compat/jei/OmniversalPatternJeiTransferHandler.java @@ -127,16 +127,22 @@ public mezz.jei.api.recipe.RecipeType getRecipe // Sent before the slot contents so the pick is on record by the time the server sees the // encoded pattern, including when a helper mod encodes it automatically on the same tick. - PacketDistributor.sendToServer(new SelectOmniversalPatternRecipePacket( - menu.containerId, - recipe.id(), - AlloyFurnaceRecipeFingerprint.create(recipe, player.level().registryAccess()), - entry.sourceId())); + selectRecipe(menu, entry, player.level()); // Forge ExtendedAE Plus has no search-preset API; it must not gate AE2 pattern transfer. EncodingHelper.encodeProcessingRecipe(menu, inputs, outputs); return null; } + /** Records an explicit recipe-viewer selection before AE2 fills the terminal slots. */ + public static void selectRecipe( + PatternEncodingTermMenu menu, AlloyFurnaceRecipeCatalog.Entry entry, + net.minecraft.world.level.Level level) { + PacketDistributor.sendToServer(new SelectOmniversalPatternRecipePacket( + menu.containerId, entry.recipe().id(), + AlloyFurnaceRecipeFingerprint.create(entry.recipe(), level.registryAccess()), + entry.sourceId())); + } + /** * One entry per input, each listing the interchangeable stacks that satisfy it so AE2 can encode * whichever the player's network actually stocks. Ordered like @@ -188,8 +194,9 @@ public static List> inputOptions(AdvancedAlloyFurnaceRecipe r return inputs; } + /** Canonical item, fluid and AE-key outputs, also used by the native EMI encoder. */ @Nullable - private static List outputs(AdvancedAlloyFurnaceRecipe recipe) { + public static List outputs(AdvancedAlloyFurnaceRecipe recipe) { if (recipe == null) return null; List outputs = new ArrayList<>(); for (ItemStack stack : recipe.outputs()) { diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/AlloyFurnaceRecipeCatalog.java b/src/main/java/com/sorrowmist/useless/content/recipe/AlloyFurnaceRecipeCatalog.java index cd8e9f08..06044619 100644 --- a/src/main/java/com/sorrowmist/useless/content/recipe/AlloyFurnaceRecipeCatalog.java +++ b/src/main/java/com/sorrowmist/useless/content/recipe/AlloyFurnaceRecipeCatalog.java @@ -510,7 +510,6 @@ private static Object buildLock(Object cacheKey) { } private static Snapshot build(Level level, long generation) { - long startedAt = System.nanoTime(); List recipes = new ArrayList<>(); for (RecipeHolder holder : com.sorrowmist.useless.compat.forge1201.recipe.RecipeManagerAccess.getAllRecipesFor(level.getRecipeManager(), ModRecipeTypes.ADVANCED_ALLOY_FURNACE_TYPE.get())) { recipes.add(new CollectedRecipe(holder.value(), RecipeSourceIds.CORE)); @@ -575,13 +574,10 @@ private static Snapshot build(Level level, long generation) { } bySource.replaceAll((ignored, entries) -> List.copyOf(entries)); bySourceAndRecipeId.replaceAll((ignored, entries) -> List.copyOf(entries)); - Snapshot snapshot = new Snapshot(ordered, Map.copyOf(unique), Map.copyOf(byRecipeId), + return new Snapshot(ordered, Map.copyOf(unique), Map.copyOf(byRecipeId), Map.copyOf(bySource), Map.copyOf(bySourceAndRecipeId), generation, new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), ConcurrentHashMap.newKeySet()); - LOGGER.debug("Built alloy-furnace recipe catalog: generation={}, recipes={}, sources={}, elapsed={} ms", - generation, ordered.size(), bySource.size(), (System.nanoTime() - startedAt) / 1_000_000L); - return snapshot; } /** 一个待做配方转换的 adapter 及其来源标识。 */ @@ -594,8 +590,8 @@ private record FingerprintedRecipe( } /** - * 为单条配方计算身份指纹。无法编码的配方返回 {@code null} 并只记一行日志:这是 - * 「可预期的坏配方」而不是故障,明细放 DEBUG 的配方字段 dump,不刷整条异常栈。 + * 为单条配方计算身份指纹。无法编码时返回 {@code null},通过限流告警保留原因, + * 避免坏配方在并行扫描时刷出大量重复日志。 * *

本方法被并行调用,因此只读取入参、不触碰任何可变共享状态。

*/ diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/RecipeAdapterCompatRegistry.java b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/RecipeAdapterCompatRegistry.java index 2f976828..c68f8654 100644 --- a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/RecipeAdapterCompatRegistry.java +++ b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/RecipeAdapterCompatRegistry.java @@ -140,6 +140,8 @@ public final class RecipeAdapterCompatRegistry { new CompatEntry(MALUM, RecipeAdapterCompatRegistry::registerMalum), new CompatEntry(ENDER_IO, RecipeAdapterCompatRegistry::registerEnderIO), new CompatEntry(CREATE, RecipeAdapterCompatRegistry::registerCreate), + new CompatEntry("tconstruct", () -> registerFactory( + "com.sorrowmist.useless.content.recipe.adapters.tconstruct.TConstructRecipeAdapter")), new CompatEntry(ORITECH, RecipeAdapterCompatRegistry::registerOritech), new CompatEntry(NEOVITAE, RecipeAdapterCompatRegistry::registerNeoVitae), new CompatEntry(UFO, RecipeAdapterCompatRegistry::registerUfo), @@ -323,10 +325,6 @@ private static void registerExtendedAE() { } private static void registerAdvancedAE() { - if (!isClassAvailable("net.pedroksl.ae2addonlib.recipes.IngredientStack$Item")) { - LOGGER.debug("AdvancedAE 1.2.x does not expose the 1.3 addon-lib recipe API; skipping reaction chamber adapter"); - return; - } registerByName("com.sorrowmist.useless.content.recipe.adapters.ae.advancedae.ReactionChamberRecipeAdapter"); } diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/advancedae/ReactionChamberRecipeAdapter.java b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/advancedae/ReactionChamberRecipeAdapter.java index 6eca206f..c3524407 100644 --- a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/advancedae/ReactionChamberRecipeAdapter.java +++ b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/advancedae/ReactionChamberRecipeAdapter.java @@ -13,30 +13,41 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.material.Fluids; import net.minecraftforge.fluids.FluidStack; -import net.pedroksl.advanced_ae.common.definitions.AAEBlocks; import net.pedroksl.advanced_ae.recipes.ReactionChamberRecipe; import java.util.*; -/** AdvancedAE 1.3 uses AE2AddonLib ingredients; its fluid predicate ignores NBT. */ +/** Supports both the native 1.2 ingredient classes and the 1.3 addon-lib API. */ public class ReactionChamberRecipeAdapter implements IRecipeAdapter { @Override public Class getRecipeClass() { return ReactionChamberRecipe.class; } - @Override public ItemStack getMoldItem() { return new ItemStack(AAEBlocks.REACTION_CHAMBER.block()); } + @Override public ItemStack getMoldItem() { + // The block-definition class/field descriptor also changed between 1.2 and 1.3. + return new ItemStack(net.minecraft.core.registries.BuiltInRegistries.ITEM.get( + new net.minecraft.resources.ResourceLocation("advanced_ae", "reaction_chamber"))); + } @Override public AdvancedAlloyFurnaceRecipe convert(RecipeHolder holder,Level level) { if (holder == null || holder.value() == null) return null; var source=holder.value(); List items=new ArrayList<>(); if (source.getInputs() == null) return null; - for (var input:source.getInputs()) { - if (input == null || input.getAmount() <= 0 || AdapterUtils.isIngredientEmpty(input.getIngredient())) return null; - items.add(new CountedIngredient(input.getIngredient(),input.getAmount())); + // Do not link IngredientStack or getFluid's return descriptor: both moved in 1.3. + for (Object input:source.getInputs()) { + if (input == null) return null; + Object value = nativeValue(input, "getIngredient"); + int amount = ((Number) nativeValue(input, "getAmount")).intValue(); + if (amount <= 0 || !(value instanceof Ingredient ingredient) || AdapterUtils.isIngredientEmpty(ingredient)) return null; + items.add(new CountedIngredient(ingredient,amount)); } List fluids=new ArrayList<>(); - var fluid=source.getFluid(); - // Zero-amount native fluid is the explicit no-fluid sentinel. - if (fluid != null && fluid.getAmount() != 0) { - if (fluid.getAmount() < 0 || fluid.getIngredient() == null || fluid.getIngredient() == Fluids.EMPTY) return null; - fluids.add(new SizedFluidIngredient(FluidIngredient.single(fluid.getIngredient()),fluid.getAmount())); + Object fluid=nativeValue(source,"getFluid"); + // Both native versions compare exact fluid IDs, ignoring NBT; zero is the no-fluid sentinel. + if (fluid != null) { + int amount=((Number)nativeValue(fluid,"getAmount")).intValue(); + if (amount != 0) { + Object value=nativeValue(fluid,"getIngredient"); + if (amount < 0 || !(value instanceof net.minecraft.world.level.material.Fluid liquid) || liquid == Fluids.EMPTY) return null; + fluids.add(new SizedFluidIngredient(FluidIngredient.single(liquid),amount)); + } } if (items.isEmpty() && fluids.isEmpty()) return null; var output=source.output; @@ -52,6 +63,14 @@ public class ReactionChamberRecipeAdapter implements IRecipeAdapter items,Map fluids) { if (recipe == null) return false; Map required=new LinkedHashMap<>(); diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapter.java b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapter.java index 6c76be77..026389b1 100644 --- a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapter.java +++ b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapter.java @@ -29,7 +29,7 @@ * 将压印器配方转换为高级合金熔炉配方 *

* 处理逻辑: - * - 压印模板(顶部/底部)→ 模具(mold) + * - 压印器机器始终是第一个模具;压印模板(顶部/底部)→ 额外模具(mold) * - INSCRIBE 模式:模具不消耗(正常模具行为) * - PRESS 模式:模具被消耗(需要特殊处理) * - 中间物品 → 普通输入(总是被消耗) @@ -51,14 +51,12 @@ public Class getRecipeClass() { @Override @Nullable public ItemStack getMoldItem() { - return null; + return new ItemStack(AEBlocks.INSCRIBER.asItem()); } @Override public boolean matchesMold(@Nullable ItemStack mold) { - if (mold == null || mold.isEmpty()) return false; - if (ItemStack.isSameItem(mold, new ItemStack(AEBlocks.INSCRIBER.asItem()))) return true; - return true; + return mold != null && !mold.isEmpty() && mold.is(AEBlocks.INSCRIBER.asItem()); } @Override @@ -84,7 +82,8 @@ public List convertAll(RecipeHolder // 构建输入列表 List countedIngredients = new ArrayList<>(); - Ingredient moldIngredient; + List molds = new ArrayList<>(); + molds.add(Ingredient.of(getMoldItem())); if (processType == InscriberProcessType.PRESS) { // PRESS 模式:所有物品(顶部、底部、中间)都放入输入栏,以 ae2:inscriber 作为模具 @@ -95,30 +94,27 @@ public List convertAll(RecipeHolder if (!isIngredientEmpty(bottomInput)) { countedIngredients.add(new CountedIngredient(bottomInput, 1)); } - moldIngredient = Ingredient.of(AEBlocks.INSCRIBER.asItem()); } else { - // INSCRIBE 模式:以顶部或底部物品作为模具(不消耗),中间物品作为输入 + // The machine and BOTH non-consumed plates are required (AND), not alternatives. + // Keeping the machine first also makes every printing recipe discoverable by inscriber. countedIngredients.add(new CountedIngredient(middleInput, 1)); - if (!isIngredientEmpty(topInput)) { - moldIngredient = topInput; - } else if (!isIngredientEmpty(bottomInput)) { - moldIngredient = bottomInput; - } else { - moldIngredient = Ingredient.of(AEBlocks.INSCRIBER.asItem()); - } + if (!isIngredientEmpty(topInput)) molds.add(topInput); + if (!isIngredientEmpty(bottomInput)) molds.add(bottomInput); } AdvancedAlloyFurnaceRecipe convertedRecipe = new AdvancedAlloyFurnaceRecipe( AdapterUtils.convertedId(originalId), countedIngredients, List.of(), + List.of(), List.of(output.copy()), List.of(), + List.of(), TOTAL_ENERGY, AE2_PROCESS_TICKS, Ingredient.EMPTY, // 催化剂(AE2压印器配方不使用催化剂) 0, - moldIngredient, // 模具 + molds, // machine + every reusable plate AlloyFurnaceMode.NORMAL ); @@ -153,46 +149,16 @@ public List> findMatchingRecipes(Level level, Map< List> matches = new java.util.ArrayList<>(); - boolean hasMold = mold != null && !mold.isEmpty(); - + if (!matchesMold(mold)) return List.of(); for (RecipeHolder holder : recipes) { - InscriberRecipe recipe = holder.value(); - - Ingredient middleInput = recipe.getMiddleInput(); - Ingredient topInput = recipe.getTopOptional(); - Ingredient bottomInput = recipe.getBottomOptional(); - InscriberProcessType processType = recipe.getProcessType(); - - if (middleInput == null || isIngredientEmpty(middleInput)) continue; - - boolean hasTop = !isIngredientEmpty(topInput); - boolean hasBottom = !isIngredientEmpty(bottomInput); - - if (processType == InscriberProcessType.PRESS) { - if (!hasMold || !ItemStack.isSameItem(mold, new ItemStack(AEBlocks.INSCRIBER.asItem()))) continue; - - Map requiredInputs = new LinkedHashMap<>(); - AdapterUtils.mergeIngredient(requiredInputs, middleInput, 1L); - if (hasTop) AdapterUtils.mergeIngredient(requiredInputs, topInput, 1L); - if (hasBottom) AdapterUtils.mergeIngredient(requiredInputs, bottomInput, 1L); - if (!AdapterUtils.matchesRequired(mergedInputs, requiredInputs)) continue; - - matches.add(holder); - } else { - boolean moldMatchesTop = hasTop && hasMold && topInput.test(mold); - boolean moldMatchesBottom = hasBottom && hasMold && bottomInput.test(mold); - - if ((hasTop || hasBottom) && (!hasMold || !moldMatchesTop && !moldMatchesBottom)) { - continue; - } - - Map requiredInputs = new LinkedHashMap<>(); - AdapterUtils.mergeIngredient(requiredInputs, middleInput, 1L); - if (!AdapterUtils.matchesRequired(mergedInputs, requiredInputs)) continue; - - matches.add(holder); + for (var converted : convertAll(holder, level)) { + Map required = new LinkedHashMap<>(); + for (var input : converted.inputs()) AdapterUtils.mergeIngredient(required, input.ingredient(), input.count()); + if (AdapterUtils.matchesRequired(mergedInputs, required)) matches.add(holder); } } + // Multi-mold recipes are subsequently checked by the mold hub; a single mold slot + // cannot satisfy the additional reusable plates. return matches; } diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateRecipeAdapterUtils.java b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateRecipeAdapterUtils.java index 6f9d1b3c..24416a39 100644 --- a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateRecipeAdapterUtils.java +++ b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateRecipeAdapterUtils.java @@ -3,6 +3,8 @@ import com.sorrowmist.useless.utils.RateLimitedLogger; import com.simibubi.create.content.fluids.transfer.FillingRecipe; +import com.simibubi.create.content.fluids.transfer.EmptyingRecipe; +import com.simibubi.create.content.kinetics.saw.CuttingRecipe; import com.simibubi.create.content.kinetics.millstone.MillingRecipe; import com.simibubi.create.content.kinetics.mixer.CompactingRecipe; import com.simibubi.create.content.kinetics.mixer.MixingRecipe; @@ -45,6 +47,7 @@ final class CreateRecipeAdapterUtils { private static final Set MACHINE_MOLD_PATHS = Set.of( "spout", + "item_drain", "mechanical_press", "mechanical_mixer", "basin", @@ -94,6 +97,8 @@ static SizedFluidIngredient nativeFluid(com.simibubi.create.foundation.fluid.Flu static boolean supportsProcessing(ProcessingRecipe recipe) { return recipe instanceof FillingRecipe + || recipe instanceof EmptyingRecipe + || recipe instanceof CuttingRecipe || recipe instanceof PressingRecipe || recipe instanceof CompactingRecipe || recipe instanceof MixingRecipe @@ -107,6 +112,10 @@ static List processingMolds(ProcessingRecipe recipe) { List molds = new ArrayList<>(); if (recipe instanceof FillingRecipe) { addBlockMold(molds, "spout"); + } else if (recipe instanceof EmptyingRecipe) { + addBlockMold(molds, "item_drain"); + } else if (recipe instanceof CuttingRecipe) { + addBlockMold(molds, "mechanical_saw"); } else if (recipe instanceof PressingRecipe) { addBlockMold(molds, "mechanical_press"); } else if (recipe instanceof CompactingRecipe) { diff --git a/src/main/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapter.java b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapter.java new file mode 100644 index 00000000..cf9af8a6 --- /dev/null +++ b/src/main/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapter.java @@ -0,0 +1,210 @@ +package com.sorrowmist.useless.content.recipe.adapters.tconstruct; + +import com.google.gson.JsonElement; +import com.mojang.logging.LogUtils; +import com.sorrowmist.useless.api.enums.AlloyFurnaceMode; +import com.sorrowmist.useless.compat.forge1201.fluid.FluidIngredient; +import com.sorrowmist.useless.compat.forge1201.recipe.RecipeHolder; +import com.sorrowmist.useless.compat.forge1201.recipe.RecipeManagerAccess; +import com.sorrowmist.useless.content.recipe.*; +import com.sorrowmist.useless.utils.RateLimitedLogger; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.level.Level; +import net.minecraftforge.fluids.FluidStack; +import java.lang.reflect.Proxy; +import java.util.*; + +/** Optional native 1.20.1 integration: no linkage to TConstruct or Mantle when absent. */ +public final class TConstructRecipeAdapter implements IRecipeAdapter> { + private static final String ROOT = "slimeknights.tconstruct.library.recipe."; + private final String type, machine; + private final Class> recipeClass; + + @SuppressWarnings("unchecked") + public TConstructRecipeAdapter(String type, String machine) throws ClassNotFoundException { + this.type = type; + this.machine = machine; + String name = switch (type) { + case "melting" -> "melting.MeltingRecipe"; + case "alloy" -> "alloying.AlloyRecipe"; + default -> "casting.ItemCastingRecipe"; + }; + recipeClass = (Class>)(Class)Class.forName(ROOT + name); + } + + public static List> createAdapters() throws ClassNotFoundException { + return List.of(new TConstructRecipeAdapter("melting", "smeltery_controller"), + new TConstructRecipeAdapter("melting", "foundry_controller"), + new TConstructRecipeAdapter("alloy", "smeltery_controller"), + new TConstructRecipeAdapter("alloy", "foundry_controller"), + new TConstructRecipeAdapter("casting_table", "seared_table"), + new TConstructRecipeAdapter("casting_table", "scorched_table"), + new TConstructRecipeAdapter("casting_basin", "seared_basin"), + new TConstructRecipeAdapter("casting_basin", "scorched_basin")); + } + @Override public String sourceId() { return "tconstruct"; } + @Override public Class> getRecipeClass() { return recipeClass; } + @Override public ItemStack getMoldItem() { + return new ItemStack(BuiltInRegistries.ITEM.get(new ResourceLocation("tconstruct", machine))); + } + + /** Reject dynamic durability, material/tool and retexturing recipes rather than invent fixed outputs. */ + public static boolean safeClass(String name) { + return Set.of(ROOT + "melting.MeltingRecipe", ROOT + "melting.OreMeltingRecipe", + ROOT + "alloying.AlloyRecipe", ROOT + "casting.ItemCastingRecipe").contains(name); + } + + @Override public List convertAll(RecipeHolder> holder, Level level) { + if (holder == null || holder.value() == null || getMoldItem().isEmpty()) return List.of(); + Recipe source = holder.value(); + if (!safeClass(source.getClass().getName())) return List.of(); + var nativeType = BuiltInRegistries.RECIPE_TYPE.getKey(source.getType()); + if (!new ResourceLocation("tconstruct", type.equals("alloy") ? "alloying" : type).equals(nativeType)) return List.of(); + try { + return switch (type) { + case "melting" -> melt(holder.id(), source); + case "alloy" -> alloy(holder.id(), source); + default -> cast(holder.id(), source); + }; + } catch (ReflectiveOperationException | IllegalArgumentException exception) { + RateLimitedLogger.warn(LogUtils.getLogger(), "Cannot convert TConstruct recipe {} for {}", holder.id(), machine, exception); + return List.of(); + } + } + + private List cast(ResourceLocation id, Object source) throws ReflectiveOperationException { + Ingredient cast = (Ingredient)call(source,"getCast"); + ItemStack result = (ItemStack)call(source,"getOutput"); + boolean consumed = (Boolean)call(source,"isConsumed"); + if (result.isEmpty()) return List.of(); + var items = new ArrayList(); + var molds = new ArrayList(); + molds.add(Ingredient.of(getMoldItem())); + addCast(cast, consumed, items, molds); + var alternatives = fluids((JsonElement)call(call(source,"getFluid"),"serialize")); + var recipes = new ArrayList(); + int time = ((Number)call(source,"getCoolingTime")).intValue(); + for (int i=0; i items, List molds) { + if (cast == null || cast.isEmpty()) return; + if (consumed) items.add(new CountedIngredient(cast,1)); + else molds.add(cast); + } + + private List melt(ResourceLocation id, Object source) throws ReflectiveOperationException { + Ingredient input = (Ingredient)call(source,"getInput"); + if (input == null || input.isEmpty()) return List.of(); + var outputs = new ArrayList(); + if (machine.equals("foundry_controller")) { + // Native API includes its configured ore rate AND independently rated byproducts. + for (Object group : (List)call(source,"getOutputWithByproducts")) { + var alternatives = (List)group; + if (alternatives.size()!=1) return List.of(); + FluidStack output = (FluidStack)alternatives.get(0); + if (!output.isEmpty()) outputs.add(output.copy()); + } + } else { + // Let native OreMeltingRecipe apply smelteryOreRate; do not hardcode a multiplier. + Class container = Class.forName(ROOT + "melting.IMeltingContainer"); + Object config = Class.forName("slimeknights.tconstruct.common.config.Config").getField("COMMON").get(null); + Object rate = config.getClass().getField("smelteryOreRate").get(config); + Object proxy = Proxy.newProxyInstance(container.getClassLoader(),new Class[]{container},(p,m,a) -> { + if (m.getName().equals("getOreRate")) return rate; + if (m.getName().equals("getStack")) return ItemStack.EMPTY; + throw new UnsupportedOperationException(m.getName()); + }); + FluidStack output = (FluidStack)source.getClass().getMethod("getOutput",container).invoke(source,proxy); + if (!output.isEmpty()) outputs.add(output.copy()); + } + if (outputs.isEmpty()) return List.of(); + return List.of(recipe(id,0,List.of(new CountedIngredient(input,1)),List.of(),List.of(),outputs, + List.of(Ingredient.of(getMoldItem())),((Number)call(source,"getTime")).intValue())); + } + + private List alloy(ResourceLocation id, Object source) throws ReflectiveOperationException { + var inputFluids = new ArrayList(); + var outputs = new ArrayList(); + FluidStack result = (FluidStack)call(source,"getOutput"); + if (result.isEmpty()) return List.of(); + outputs.add(result.copy()); + for (Object input : (List)call(source,"getInputs")) { + var choices = fluids((JsonElement)call(call(input,"fluid"),"serialize")); + // A varying-amount compound cannot be collapsed into a cheaper fixed-size predicate. + if (choices.size()!=1) return List.of(); + var fluid = choices.get(0); + inputFluids.add(fluid); + if ((Boolean)call(input,"catalyst")) { + var stacks = fluid.ingredient().getStacks(); + // Catalytic fluid is returned, not consumed. Only an unambiguous native fluid + // can be returned without changing which member of a tag was supplied. + if (stacks.length!=1 || fluid.amount()>Integer.MAX_VALUE) return List.of(); + outputs.add(new FluidStack(stacks[0],(int)fluid.amount())); + } + } + if (inputFluids.isEmpty()) return List.of(); + return List.of(recipe(id,0,List.of(),inputFluids,List.of(),outputs,List.of(Ingredient.of(getMoldItem())), + AdapterUtils.DEFAULT_PROCESS_TIME)); + } + + private AdvancedAlloyFurnaceRecipe recipe(ResourceLocation id, int variant, List items, + List fluids, List outputs, List outputFluids, + List molds, int time) { + if (time<0) throw new IllegalArgumentException("Negative native recipe time"); + return new AdvancedAlloyFurnaceRecipe(new ResourceLocation(id.getNamespace(),id.getPath()+"_"+machine+"_"+variant+"_converted"), + items,fluids,List.of(),outputs,outputFluids,List.of(),AdapterUtils.DEFAULT_ENERGY, + Math.max(1,time),Ingredient.EMPTY,0,molds,AlloyFurnaceMode.NORMAL); + } + + /** Preserve Mantle's exact fluid IDs and live tags (native predicates ignore NBT). */ + static List fluids(JsonElement json) { + var byAmount = new LinkedHashMap>(); + collectFluids(json,byAmount); + var choices=byAmount.entrySet().stream().map(e -> new LongSizedFluidIngredient(FluidIngredient.any(e.getValue()),e.getKey())).toList(); + // Mantle compounds can overlap. Splitting one into alternative costs must not let a + // later, cheaper branch bypass the native cost of the same fluid in this generation. + for (int i=0;i> out) { + if (json.isJsonArray()) { for (var child:json.getAsJsonArray()) collectFluids(child,out); return; } + var object=json.getAsJsonObject().deepCopy(); + long amount=object.get("amount").getAsLong(); + if (amount<=0 || (!object.has("fluid") && !object.has("tag"))) throw new IllegalArgumentException("Invalid Mantle fluid predicate"); + FluidIngredient ingredient=FluidIngredient.fromJson(object); + if (ingredient.isEmpty()) throw new IllegalArgumentException("Empty Mantle fluid predicate"); + out.computeIfAbsent(amount,k->new ArrayList<>()).add(ingredient); + } + static Object call(Object target, String method) throws ReflectiveOperationException { + return target.getClass().getMethod(method).invoke(target); + } + + @Override public List>> findMatchingRecipes(Level level, Map items, + Map fluids, ItemStack mold) { + if (level==null || !matchesMold(mold)) return List.of(); + var matches = new ArrayList>>(); + for (var holder:RecipeManagerAccess.getRecipes(level.getRecipeManager())) { + @SuppressWarnings("unchecked") var typed=(RecipeHolder>)(RecipeHolder)holder; + for (var converted:convertAll(typed,level)) { + var required=new LinkedHashMap(); + for (var input:converted.inputs()) AdapterUtils.mergeIngredient(required,input.ingredient(),input.count()); + if (AdapterUtils.matchesRequired(items,required) && FluidIngredientAllocator.matchesLong(converted.inputFluids(),fluids,1L)) { + matches.add(typed); break; + } + } + } + return matches; + } +} diff --git a/src/main/java/com/sorrowmist/useless/core/config/ConfigManager.java b/src/main/java/com/sorrowmist/useless/core/config/ConfigManager.java index 302528f5..3c17630f 100644 --- a/src/main/java/com/sorrowmist/useless/core/config/ConfigManager.java +++ b/src/main/java/com/sorrowmist/useless/core/config/ConfigManager.java @@ -157,6 +157,7 @@ public class ConfigManager { private static final ForgeConfigSpec.BooleanValue ENABLE_MALUM_RECIPE_CONVERSION; private static final ForgeConfigSpec.BooleanValue ENABLE_ENDER_IO_RECIPE_CONVERSION; private static final ForgeConfigSpec.BooleanValue ENABLE_CREATE_RECIPE_CONVERSION; + private static final ForgeConfigSpec.BooleanValue ENABLE_TCONSTRUCT_RECIPE_CONVERSION; private static final ForgeConfigSpec.BooleanValue ENABLE_ORITECH_RECIPE_CONVERSION; private static final ForgeConfigSpec.BooleanValue ENABLE_NEOVITAE_RECIPE_CONVERSION; private static final ForgeConfigSpec.BooleanValue ENABLE_UFO_RECIPE_CONVERSION; @@ -756,6 +757,8 @@ public class ConfigManager { "enable_bigreactors_recipe_conversion", true); ENABLE_CREATE_RECIPE_CONVERSION = defineRecipeConversionOption( "enable_create_recipe_conversion", true); + ENABLE_TCONSTRUCT_RECIPE_CONVERSION = defineRecipeConversionOption( + "enable_tconstruct_recipe_conversion", true); ENABLE_DRACONIC_EVOLUTION_RECIPE_CONVERSION = defineRecipeConversionOption( "enable_draconic_evolution_recipe_conversion", true); ENABLE_ENDER_IO_RECIPE_CONVERSION = defineRecipeConversionOption( @@ -830,6 +833,7 @@ public class ConfigManager { Map.entry("casualnessdelight", ENABLE_CASUALNESS_DELIGHT_RECIPE_CONVERSION), Map.entry("crabbersdelight", ENABLE_CRABBERS_DELIGHT_RECIPE_CONVERSION), Map.entry("create", ENABLE_CREATE_RECIPE_CONVERSION), + Map.entry("tconstruct", ENABLE_TCONSTRUCT_RECIPE_CONVERSION), Map.entry("data_energistics", ENABLE_DATA_ENERGISTICS_RECIPE_CONVERSION), Map.entry("draconicevolution", ENABLE_DRACONIC_EVOLUTION_RECIPE_CONVERSION), Map.entry("enderio", ENABLE_ENDER_IO_RECIPE_CONVERSION), diff --git a/src/main/java/com/sorrowmist/useless/mixin/emi/EmiEncodePatternHandlerMixin.java b/src/main/java/com/sorrowmist/useless/mixin/emi/EmiEncodePatternHandlerMixin.java new file mode 100644 index 00000000..282f5dbd --- /dev/null +++ b/src/main/java/com/sorrowmist/useless/mixin/emi/EmiEncodePatternHandlerMixin.java @@ -0,0 +1,47 @@ +package com.sorrowmist.useless.mixin.emi; + +import appeng.api.stacks.GenericStack; +import appeng.integration.modules.jeirei.EncodingHelper; +import appeng.menu.me.items.PatternEncodingTermMenu; +import com.sorrowmist.useless.compat.jei.OmniversalEmiSelection; +import com.sorrowmist.useless.compat.jei.OmniversalPatternJeiTransferHandler; +import dev.emi.emi.api.recipe.EmiRecipe; +import net.minecraft.client.Minecraft; +import net.minecraft.world.item.crafting.Recipe; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.util.List; + +/** AE2's native EMI handler bypasses category-specific JEI transfer handlers. */ +@Pseudo +@Mixin(targets = "appeng.integration.modules.emi.EmiEncodePatternHandler", remap = false) +public abstract class EmiEncodePatternHandlerMixin { + @Redirect(method = "transferRecipe(Lappeng/menu/me/items/PatternEncodingTermMenu;Lnet/minecraft/world/item/crafting/Recipe;Ldev/emi/emi/api/recipe/EmiRecipe;Z)Lappeng/integration/modules/emi/AbstractRecipeHandler$Result;", + at = @At(value = "INVOKE", target = "Lappeng/integration/modules/jeirei/EncodingHelper;encodeProcessingRecipe(Lappeng/menu/me/items/PatternEncodingTermMenu;Ljava/util/List;Ljava/util/List;)V")) + private void useless$encodeOmniversalRecipe( + PatternEncodingTermMenu menu, List> inputs, List outputs, + PatternEncodingTermMenu originalMenu, Recipe backingRecipe, + EmiRecipe displayedRecipe, boolean doTransfer) { + var entry = OmniversalEmiSelection.recipe(displayedRecipe); + if (entry != null && Minecraft.getInstance().level != null) { + var canonicalInputs = OmniversalPatternJeiTransferHandler.inputOptions(entry.recipe()); + var canonicalOutputs = OmniversalPatternJeiTransferHandler.outputs(entry.recipe()); + if (canonicalInputs != null && canonicalOutputs != null + && !canonicalInputs.isEmpty() && !canonicalOutputs.isEmpty() + && canonicalInputs.size() <= menu.getProcessingInputSlots().length + && canonicalOutputs.size() <= menu.getProcessingOutputSlots().length) { + // EMI's generic conversion drops custom AE keys (e.g. AppMek infusion types). + // Keep the exact keys/amounts and AE2's normal network substitution/encoding. + inputs = canonicalInputs; + outputs = canonicalOutputs; + OmniversalPatternJeiTransferHandler.selectRecipe(menu, entry, Minecraft.getInstance().level); + } + } + // A direct redirect avoids Mixin's synthetic Args classes, which Forge 47's module + // classloader cannot load. All unrelated recipe pages retain their native arguments. + EncodingHelper.encodeProcessingRecipe(menu, inputs, outputs); + } +} diff --git a/src/main/resources/assets/useless_mod/lang/en_us.json b/src/main/resources/assets/useless_mod/lang/en_us.json index 1773e212..789e9af9 100644 --- a/src/main/resources/assets/useless_mod/lang/en_us.json +++ b/src/main/resources/assets/useless_mod/lang/en_us.json @@ -730,6 +730,9 @@ "useless_mod.configuration.enable_ender_io_recipe_conversion": "Enable Ender IO Recipe Conversion", "useless_mod.configuration.enable_ender_io_recipe_conversion.comment": "Whether to enable conversion of recipes from this source. Takes effect after restarting the game.", "useless_mod.configuration.enable_ender_io_recipe_conversion.tooltip": "Whether to enable conversion of recipes from this source. Takes effect after restarting the game.", + "useless_mod.configuration.enable_tconstruct_recipe_conversion": "Enable Tinkers' Construct Recipe Conversion", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.comment": "Convert native melting, alloying and casting recipes while preserving cast requirements. Requires a restart.", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.tooltip": "Convert native melting, alloying and casting recipes while preserving cast requirements. Requires a restart.", "useless_mod.configuration.enable_create_recipe_conversion": "Enable Create Recipe Conversion", "useless_mod.configuration.enable_create_recipe_conversion.comment": "Whether to enable conversion of recipes from this source. Takes effect after restarting the game.", "useless_mod.configuration.enable_create_recipe_conversion.tooltip": "Whether to enable conversion of recipes from this source. Takes effect after restarting the game.", diff --git a/src/main/resources/assets/useless_mod/lang/zh_cn.json b/src/main/resources/assets/useless_mod/lang/zh_cn.json index 7e447f4d..18d8d853 100644 --- a/src/main/resources/assets/useless_mod/lang/zh_cn.json +++ b/src/main/resources/assets/useless_mod/lang/zh_cn.json @@ -731,6 +731,9 @@ "useless_mod.configuration.enable_ender_io_recipe_conversion": "启用Ender IO配方转换", "useless_mod.configuration.enable_ender_io_recipe_conversion.comment": "是否启用该配方来源的配方转换,修改后重启游戏生效", "useless_mod.configuration.enable_ender_io_recipe_conversion.tooltip": "是否启用该配方来源的配方转换,修改后重启游戏生效", + "useless_mod.configuration.enable_tconstruct_recipe_conversion": "启用匠魂配方转换", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.comment": "转换匠魂熔炼、合金和铸造配方,保留铸模条件;修改后重启游戏生效", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.tooltip": "转换匠魂熔炼、合金和铸造配方,保留铸模条件;修改后重启游戏生效", "useless_mod.configuration.enable_create_recipe_conversion": "启用Create配方转换", "useless_mod.configuration.enable_create_recipe_conversion.comment": "是否启用该配方来源的配方转换,修改后重启游戏生效", "useless_mod.configuration.enable_create_recipe_conversion.tooltip": "是否启用该配方来源的配方转换,修改后重启游戏生效", diff --git a/src/main/resources/assets/useless_mod/lang/zh_tw.json b/src/main/resources/assets/useless_mod/lang/zh_tw.json index 9b392edc..f1da162b 100644 --- a/src/main/resources/assets/useless_mod/lang/zh_tw.json +++ b/src/main/resources/assets/useless_mod/lang/zh_tw.json @@ -726,6 +726,9 @@ "useless_mod.configuration.enable_ender_io_recipe_conversion": "啟用Ender IO配方轉換", "useless_mod.configuration.enable_ender_io_recipe_conversion.comment": "是否啟用該配方來源的配方轉換,修改後重啟遊戲生效", "useless_mod.configuration.enable_ender_io_recipe_conversion.tooltip": "是否啟用該配方來源的配方轉換,修改後重啟遊戲生效", + "useless_mod.configuration.enable_tconstruct_recipe_conversion": "啟用匠魂配方轉換", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.comment": "轉換匠魂熔煉、合金和鑄造配方,保留鑄模條件;修改後重啟遊戲生效", + "useless_mod.configuration.enable_tconstruct_recipe_conversion.tooltip": "轉換匠魂熔煉、合金和鑄造配方,保留鑄模條件;修改後重啟遊戲生效", "useless_mod.configuration.enable_create_recipe_conversion": "啟用Create配方轉換", "useless_mod.configuration.enable_create_recipe_conversion.comment": "是否啟用該配方來源的配方轉換,修改後重啟遊戲生效", "useless_mod.configuration.enable_create_recipe_conversion.tooltip": "是否啟用該配方來源的配方轉換,修改後重啟遊戲生效", diff --git a/src/main/resources/assets/useless_mod/textures/gui/addition.png b/src/main/resources/assets/useless_mod/textures/gui/addition.png deleted file mode 100644 index 04a74001bbf8841871b8127c34616ffb09f831a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1231 zcmV;=1Tg!FP)Px(iAh93R7gv$mI-WBMHt6_-@GlQ(f|tGQWjE745$PO2yH>4q@W;Y1PSo~j|4nO zFa#nY7!qTU5XE>!Fa%EowI19M3o$?`1&p{95RFDT%FzO;RT0~L@6&k$yQ~F@hDkO% zyYIcLOSi&z2O26T-XCu<15D_cr2!8F3Fd*qwj8X98l`gQH!ca=KTEX5*=sS|=j~l4T zmLyAS%96=ksdtuVatfQUgcH8fV+iaigIoH5t(4V6)ov(o0UAOZU-pkyL!`4IBVe`9F?<)4M^|2IoGqso})V8yd=b3b=OR%^&^ld8N3l&=cg zCprD0(Q3lL{&JWw4?dm)og$ZK#kZtb1IIY)Q4cDsZ>TG+iF?s=S6@4vwxy|XUO+O-CXN2akDFaiP(Dp$kc zhnuj3YF`;P4K}W+X~eHzDF_ZAF)stHRt?kl5az{;1P2&Bn&b>l%Z-Uw%oRu72>$dmU8E%cE z2f<{yOxL^ukG>4=&4iAoSr#3-raa2BK3@pM_cdWT!<#=Hwl0U!PrDay{Yi-CyIuC_ z0;i|`&tQFz(?_AfhoBKEN|8a6VAhK%^Sl(xvM-GBvt0Wut8_jLd;rRqL*HRYb~I#( zsjAmt_A*#J#Vy`zDx9r{_Id7~;DVD)7;fxY%;9eQw?tKbuhiM~CRny93)7&Xo5 zZ{2G!es+pw1DP<_u|9d;g*sro`|de7WC@28%8&=)u1PR^Xmc`1yY9{S0RO;NC|e30 zOp>MFe{|K3F5T{jR_*b+-j-yAE-Y(Kz;!Lr<%{sLWBA7o;dRW%|0(W*$ZG3;A3GFQ zDIBpb@Ys{!2T;-i+ImTr+4Lp;@74dCzx^L5Ji)X2KmgS_I%5XQr7E$OKt- t937hn35DPsvbGK#o(r*y4tdvr^*5;*kh>I^!|(tA002ovPDHLkV1h|uHVFU# diff --git a/src/main/resources/assets/useless_mod/textures/gui/advanced_alloy_furnace_gui.png b/src/main/resources/assets/useless_mod/textures/gui/advanced_alloy_furnace_gui.png deleted file mode 100644 index 4dee6dc013a8a8efd13854fa48bac427ee2a77d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3781 zcmbVP2{_d0A9rnJlSC!!m`0P7IgDe*Fsw6^hC-PR$C&x0S#y~g#!)DR%6*lVJi1sb zilodqij>$=StDH|HYCS}EY`HYaXoEY|9$>D&pW?)fA9PGe!lnjdFJ?b*DWf_TFNpq zGAaaT2Y2xIij0h`g3=Oj7yo0;aqwp;+j%!vM&?^}=~q_fOpd0EjKUDjlf)wtx8W!( z27*jw`2&bh1{*}n$XMBgvdNS{fCuph0%%M;bXZUeh0v&YsFxWLMP%Cp2WZaW9Ka*o z)sqq)NU@+oZLA?yp*WC$0r1F>PzIgJ#f9Rbv%ENPFFi&=A+r!(ARcNfRR|#ww?ph% z8~|d0Fosi5C^Q6Pfxuw>G3F*%BM2IWHbbJ&NMmETu?5Z;g+pN>^B*Xvjzis#b9Zo> zR|dY}p$B+8HV%p8^Z5wA34+B5KpIeDQcN?F9QgE{nBV}p<3%j{0E@@s9$)R%h0#@TZKGLOaaWU=TAncBW!G6ZdoFoSILqA{r~KG*OQ0lJK+JHMf8R6jL-DYfdJ^(Ew#X3XMTw$Y|_>yaS68EY0kKJoW#ScjM5& z)*{pYGmo_4q$PoKrg6b!h0S+_2M{#BqthU>Re&Q?q}7dwQlx1GsL=Ui+JBC~94-F< z0FwTXbkD)Kto=MbnFH7cfYJKbazcW}Bc**f=Lhn07iaH2>DdK1I0&TM#SsL)EDkY% z2~I8!IHIhcouY!%@DRbl)-yC`@P%7^Mz8GM-(Lv?6iP(=#a8|GG<`QzK|BG9d;#kQT!&KcBof5f#?V$!M_ z%}gwcDKEVPUh-dN^WTI;4v)^{lnJI<(sPle4_l_KhpBEhW+FAeADq%8l9ze*59JHr z9qaY=SxQQ)AqllO2+H^$mj?3-Ry5rFd5ygrnSPz|Vm-k|;W=;k&+feZ0@RMUya@9# zjSbN3XjPkuVoAscdLM0D!}GIv@Y2{L-vG6=x5Ovo(uJGVzeM@>ei%xZ#l6(s)BZKQtQ+zBH-hcJ%Dl!8d%X{OnSb-08QYtsdEh z7v!=ol#39BUP-tDNU8SHyk@ojzqI;cq)-#jgDJg56{GUOM@TeW_H zhIfS@T7{qwX!VJ7^DDf&OB1YJl<{I16zCL!v4$ZAJ>G-b*-;F`hEEd zRI%D8YgJ8Rdo}ekt;h%0+4d^tN5qc4`L0w$A~Y4}&G78u>c<({ruJn4uZucoMv(SyviK3`@^&T7c9I>Wa8xfU zWb|>--p8`)rL|N(wK%cTw}mK?xwpZ2N@-bClkVX) ztCQX~9IxDU=tc9@?kyY4V9VA$jZW6FDcj_4H=Z%J^4xdE2Vpu_92$nwR7w7m;)G~& zBDi$cUTIX*Z0|^w4g6H$oZ8QyS?|tb8@k7)^;Bl~OEqnGPVVdRA=%Yw$=2`bU-?=r z$*r*3?k(zytvbt(4Dn65>v;8c^i5=ka-{0-$`a_%SX^H5)KsWL=#J-DzHU$pO%yHHZVgmi ze)Mc3-apstfI?2XY7c*cwH%Xb2EH#-x)H~c%m96cV;TcOJE%BE2U*R-r1^gAnjjmb zs}BhMQ(8{muoZ^(rSJ?=)~M6%0)z$sZ5)$%n0BdyB@;S_NOq3aSEc-f68G z=~uN`+sA3yk=JvO9u+q6BDL|p-tK`MuT5I3u9%V<95GEkLY`yVwczF$d7C$uAEyTD zqRfX!Sdo?^!q&ara#>4kR`P?-XGSXVe}A00Rn2$$;jvo=8~Y_&A|Fizs4~LDLs3y- z{k8TVvo##njCAT~olo#s9rWl?$Uv6j<}EpuACzwtdBtWrKAj1(?(um$b?Ae7xFYfV zD@8>3`_)MAM_|7u(O6y7!RY7 zyZW~0)$aocDevFgo?tKCQD7?x8*ma{V~?Qtl?RdvwahvZ9-@_mOLh z5(FQdpKpG?X`pK;F0QxRn}pTsvA;9mb3Q48=)F__)QWm*nu`l&S~ob(Th*oOHPLoN zpZ&tFcGH7qBn+jSsC;9qFh)o;Zy_XF-PWmf`UW&KJ|eeGa~EG_-#A_zcijp#BKUTv zgsu+LVSD91dUN%tW^IqWZm+w423ru&vBV-Xr>sXl>+SBX8s5*g{|a$Al5t8UOyS_& z&dR1DZHCBaXKWJzEsn$tS{J^>jFxZ<9K12=ZUm0(-73nu_OLr8sR6H3lBh{_hRn*J zTpo?9*84=cpIR^H+GT#ed#oj0uod}wX|Qr0Oks(;#({eSZc3W+1FL@MJ6WKlnb{Mg zqEni2PfK(B#JcAtx1*mfSKsyPnmpp6niG+#7q^JptoK4!*GD$qDKu~h8SXG5;6C>M zwz1JLVmck>P~R5J&+BOHFEI79-YaL^?X5)8_ajkMwC_zO+X#9ij6_BH;^_CcJ}7Jb zri6tIDO%y$rA_~Z)p$H1_UW}Cd;Popar&s@AO+@WQiyw-nd?(?LpkQ@ju7{R>2TGB zso`{{iV>-yKtB>1q3E56bHs=?yu~+SDjmp`!lGRXKzz^9@!Gv(3oTup{+we#7D6GVUA!lqj@mVs*v zN~W(SPS-Gx$rgmy!o3WweT~-nNvG9U*5wgTbe>zRrbaFlS$(><=O+_=d?+1rlA>K2 zbRV%{vkLb5k3wVDGu>nLUz^2Wn9j(8=U}Mestwo#ljUHzE{`c`H)l>Jq`$!e&pJC& zZ(&!1ge&E~Fxmgf2A(WP%#Pbz*d59BNy{2(4cM70N25QF)K`L7@wnMgb?DXZX8BBS zQ4?ra!tLlIntsM2B zgxYvjvq0}_W%ke7Yn+z|fz@ld%+_!7`u?f$f!tPg*`n~S$x~kKby0sliOBDjTXqag iDv&ld=D`KC)*mtWO9?Kiw9rlZN0Z>_>QG|mcjPZ%a75Dp diff --git a/src/main/resources/assets/useless_mod/textures/icon/master.png b/src/main/resources/assets/useless_mod/textures/icon/master.png deleted file mode 100644 index 9a08313d10854bc18c30c4f99c00088cdfeecd78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 198 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`y`CHC_6=L-xjr!r_uBbK=h&kp!n w#U9L^?edV%t$zF8bq7w#t!&8Qjw`ZbbY}NwN%^St1?VsaPgg&ebxsLQ0Nfc$wEzGB diff --git a/src/main/resources/useless_mod.mixins.json b/src/main/resources/useless_mod.mixins.json index 71631102..347a5a63 100644 --- a/src/main/resources/useless_mod.mixins.json +++ b/src/main/resources/useless_mod.mixins.json @@ -53,6 +53,7 @@ "botanypots.BotanyPotRendererMixin", "botanypots.PhasedDisplayStateRendererMixin", "eae.GuiAssemblerMatrixPatternInfoMixin", + "emi.EmiEncodePatternHandlerMixin", "emi.JemiAEKeyMixin", "emi.JemiAEKeyRegistrationMixin" ], diff --git a/src/portBridgeTest/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateNativeRecipeTest.java b/src/portBridgeTest/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateNativeRecipeTest.java index fa819e03..458a152a 100644 --- a/src/portBridgeTest/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateNativeRecipeTest.java +++ b/src/portBridgeTest/java/com/sorrowmist/useless/content/recipe/adapters/create/CreateNativeRecipeTest.java @@ -91,6 +91,22 @@ class CreateNativeRecipeTest { assertEquals(1,recipe.outputs().get(0).getCount()); } + @Test void cuttingAndKubeJsPaintBallDrainingAreSupportedWithFullOutputs() { + for (var type : List.of(com.simibubi.create.content.kinetics.saw.CuttingRecipe.class, + com.simibubi.create.content.fluids.transfer.EmptyingRecipe.class)) { + var source=mock(type); + when(source.getIngredients()).thenReturn(NonNullList.of(Ingredient.EMPTY,Ingredient.of(Items.SLIME_BALL))); + when(source.getFluidIngredients()).thenReturn(NonNullList.create()); + when(source.getRollableResults()).thenReturn(List.of(new ProcessingOutput(new ItemStack(Items.SLIME_BALL),1))); + when(source.getFluidResults()).thenReturn(NonNullList.of(FluidStack.EMPTY,new FluidStack(Fluids.WATER,250))); + assertTrue(CreateRecipeAdapterUtils.supportsProcessing(source)); + var converted=CreateRecipeAdapterUtils.convertProcessing(new ResourceLocation("kubejs:ae2/draining_green_ball"), + source,List.of(Ingredient.of(Items.HOPPER)),LoggerFactory.getLogger(CreateNativeRecipeTest.class)); + assertNotNull(converted); assertEquals(250,converted.outputFluids().get(0).getAmount()); + assertEquals(1,converted.inputs().get(0).count()); assertEquals(1,converted.outputs().size()); + } + } + private static final class AssemblyFixture extends SequencedAssemblyRecipe { AssemblyFixture(PressingRecipe step) { super(ID,null); diff --git a/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiEncodingTest.java b/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiEncodingTest.java new file mode 100644 index 00000000..1f3e974a --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiEncodingTest.java @@ -0,0 +1,94 @@ +package com.sorrowmist.useless.compat.jei; + +import appeng.api.stacks.AEKey; +import appeng.api.stacks.GenericStack; +import appeng.integration.modules.jeirei.EncodingHelper; +import appeng.menu.me.items.PatternEncodingTermMenu; +import appeng.menu.slot.FakeSlot; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.compat.forge1201.network.PacketDistributor; +import com.sorrowmist.useless.content.recipe.AdvancedAlloyFurnaceRecipe; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeCatalog; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeFingerprint; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeIdentity; +import com.sorrowmist.useless.mixin.emi.EmiEncodePatternHandlerMixin; +import dev.emi.emi.api.recipe.EmiRecipe; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.crafting.Recipe; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OmniversalEmiEncodingTest { + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + + @Test void nativeEncoderReplacesLossyEmiConversionsWithExactCustomKeysAndAmounts() throws Exception { + var recipe = mock(AdvancedAlloyFurnaceRecipe.class); + when(recipe.id()).thenReturn(new ResourceLocation("test", "infusing")); + var input = new GenericStack(mock(AEKey.class), 80_000_000L); + var output = new GenericStack(mock(AEKey.class), 45_000_000L); + when(recipe.inputs()).thenReturn(List.of()); + when(recipe.inputFluids()).thenReturn(List.of()); + when(recipe.outputs()).thenReturn(List.of()); + when(recipe.outputFluids()).thenReturn(List.of()); + when(recipe.keyInputs()).thenReturn(List.of(input)); + when(recipe.keyOutputs()).thenReturn(List.of(output)); + var entry = new AlloyFurnaceRecipeCatalog.Entry(new AlloyFurnaceRecipeIdentity(recipe.id(), "fingerprint"), recipe, "appmek"); + var displayed = mock(EmiRecipe.class); + var menu = mock(PatternEncodingTermMenu.class); + when(menu.getProcessingInputSlots()).thenReturn(new FakeSlot[9]); + when(menu.getProcessingOutputSlots()).thenReturn(new FakeSlot[3]); + var mc = mock(Minecraft.class); + mc.level = mock(ClientLevel.class); + when(mc.level.registryAccess()).thenReturn(RegistryAccess.EMPTY); + try (var minecraft = mockStatic(Minecraft.class); + var selection = mockStatic(OmniversalEmiSelection.class); + var fingerprints = mockStatic(AlloyFurnaceRecipeFingerprint.class); + var packets = mockStatic(PacketDistributor.class); + var encoding = mockStatic(EncodingHelper.class)) { + minecraft.when(Minecraft::getInstance).thenReturn(mc); + selection.when(() -> OmniversalEmiSelection.recipe(displayed)).thenReturn(entry); + fingerprints.when(() -> AlloyFurnaceRecipeFingerprint.create(recipe, RegistryAccess.EMPTY)).thenReturn("fingerprint"); + invoke(menu, List.of(), List.of(), displayed); + encoding.verify(() -> EncodingHelper.encodeProcessingRecipe(menu, List.of(List.of(input)), List.of(output))); + packets.verify(() -> PacketDistributor.sendToServer(any(com.sorrowmist.useless.compat.forge1201.network.CustomPacketPayload[].class))); + } + } + + @Test void unrelatedRecipeKeepsNativeEncodingArgumentsUntouched() throws Exception { + var menu = mock(PatternEncodingTermMenu.class); + List> inputs = List.of(List.of(new GenericStack(mock(AEKey.class), 1))); + List outputs = List.of(new GenericStack(mock(AEKey.class), 2)); + try (var packets = mockStatic(PacketDistributor.class); + var encoding = mockStatic(EncodingHelper.class)) { + invoke(menu, inputs, outputs, mock(EmiRecipe.class)); + encoding.verify(() -> EncodingHelper.encodeProcessingRecipe(menu, inputs, outputs)); + packets.verifyNoInteractions(); + } + } + + @Test void encoderDoesNotDependOnForgeInaccessibleSyntheticArgsClasses() { + for (Method method : EmiEncodePatternHandlerMixin.class.getDeclaredMethods()) { + for (Class parameter : method.getParameterTypes()) { + assertFalse(parameter.getName().startsWith("org.spongepowered.asm.mixin.injection.invoke.arg.Args")); + } + } + } + + private static void invoke(PatternEncodingTermMenu menu, List> inputs, + List outputs, EmiRecipe recipe) throws Exception { + Method method = EmiEncodePatternHandlerMixin.class.getDeclaredMethod("useless$encodeOmniversalRecipe", + PatternEncodingTermMenu.class, List.class, List.class, PatternEncodingTermMenu.class, + Recipe.class, EmiRecipe.class, boolean.class); + method.setAccessible(true); + method.invoke(new EmiEncodePatternHandlerMixin() { }, menu, inputs, outputs, menu, null, recipe, true); + } +} diff --git a/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelectionTest.java b/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelectionTest.java new file mode 100644 index 00000000..845f4907 --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/compat/jei/OmniversalEmiSelectionTest.java @@ -0,0 +1,39 @@ +package com.sorrowmist.useless.compat.jei; + +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeCatalog; +import dev.emi.emi.api.recipe.EmiRecipe; +import dev.emi.emi.api.recipe.EmiRecipeCategory; +import dev.emi.emi.jemi.JemiRecipe; +import net.minecraft.resources.ResourceLocation; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OmniversalEmiSelectionTest { + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + + @Test void nativeAe2EmiPathRetainsTheExactImportedCatalogEntry() { + var entry = mock(AlloyFurnaceRecipeCatalog.Entry.class); + var recipe = imported("useless_mod:advanced_alloy_furnace", entry); + assertSame(entry, OmniversalEmiSelection.recipe(recipe)); + } + + @Test void normalMachinePagesAndUnrelatedRecipesNeverSelectAnOmniversalPattern() { + assertNull(OmniversalEmiSelection.recipe(imported("mekanism:crushing", mock(AlloyFurnaceRecipeCatalog.Entry.class)))); + assertNull(OmniversalEmiSelection.recipe(imported("useless_mod:advanced_alloy_furnace", "not a catalog entry"))); + assertNull(OmniversalEmiSelection.recipe(mock(EmiRecipe.class))); + assertNull(OmniversalEmiSelection.recipe(null)); + } + + private static JemiRecipe imported(String categoryId, Object entry) { + JemiRecipe recipe = mock(JemiRecipe.class); + recipe.recipe = entry; + var category = mock(EmiRecipeCategory.class); + when(category.getId()).thenReturn(new ResourceLocation(categoryId)); + when(recipe.getCategory()).thenReturn(category); + return recipe; + } +} diff --git a/src/test/java/com/sorrowmist/useless/compat/jei/UsefulIngotJeiTest.java b/src/test/java/com/sorrowmist/useless/compat/jei/UsefulIngotJeiTest.java new file mode 100644 index 00000000..4731474a --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/compat/jei/UsefulIngotJeiTest.java @@ -0,0 +1,171 @@ +package com.sorrowmist.useless.compat.jei; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.api.enums.AlloyFurnaceMode; +import com.sorrowmist.useless.content.machines.advanced_alloy_furnace.catalyst.CatalystParallelManager; +import com.sorrowmist.useless.content.recipe.AdvancedAlloyFurnaceRecipe; +import com.sorrowmist.useless.content.recipe.AdvancedAlloyFurnaceRecipeSerializer; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeCatalog; +import com.sorrowmist.useless.content.recipe.AlloyFurnaceRecipeIdentity; +import com.sorrowmist.useless.content.recipe.LongSizedFluidIngredient; +import com.sorrowmist.useless.compat.forge1201.fluid.FluidIngredient; +import io.netty.buffer.Unpooled; +import mezz.jei.api.forge.ForgeTypes; +import mezz.jei.api.gui.builder.IRecipeLayoutBuilder; +import mezz.jei.api.gui.builder.IRecipeSlotBuilder; +import mezz.jei.api.gui.builder.ITooltipBuilder; +import mezz.jei.api.gui.ingredient.IRecipeSlotRichTooltipCallback; +import mezz.jei.api.gui.ingredient.IRecipeSlotView; +import mezz.jei.api.recipe.IFocusGroup; +import mezz.jei.api.recipe.RecipeIngredientRole; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.material.Fluids; +import net.minecraftforge.fluids.FluidStack; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Layout checks also run with the pack's older JEI jar substituted on the test runtime classpath. */ +class UsefulIngotJeiTest { + private static final ResourceLocation ID = new ResourceLocation("useless_mod", "advanced_alloy/ingot/useful_ingot"); + + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + + private static AdvancedAlloyFurnaceRecipe generatedRecipe() throws Exception { + JsonObject json = JsonParser.parseString(Files.readString(Path.of( + "src/generated/resources/data/useless_mod/recipes/advanced_alloy/ingot/useful_ingot.json"))) + .getAsJsonObject(); + assertEquals("useless_mod:advanced_alloy_furnace", json.get("type").getAsString()); + assertEquals(ID.toString(), json.get("id").getAsString()); + var inputs = json.getAsJsonArray("ingredients"); + assertEquals(6, inputs.size()); + assertEquals("useless_mod:possible_useful_ingot", inputs.get(0).getAsJsonObject() + .getAsJsonObject("ingredient").get("item").getAsString()); + assertEquals("useless_mod:useful_ingot", json.getAsJsonArray("outputs").get(0) + .getAsJsonObject().get("id").getAsString()); + // Plain JUnit has no mod registry events. Only these two mod items use vanilla stand-ins; + // all counts, block ingredients, fluid JSON and serializer code remain the shipped data. + inputs.get(0).getAsJsonObject().getAsJsonObject("ingredient").addProperty("item", "minecraft:copper_ingot"); + json.getAsJsonArray("outputs").get(0).getAsJsonObject().addProperty("id", "minecraft:netherite_ingot"); + return AdvancedAlloyFurnaceRecipeSerializer.INSTANCE.fromJson(ID, json); + } + + @Test void shippedUsefulIngotRecipeLoadsAndSynchronizesWithoutLosingQuantities() throws Exception { + var recipe = generatedRecipe(); + assertEquals(List.of(64L, 1024L, 1024L, 1024L, 1024L, 1024L), + recipe.inputs().stream().map(input -> input.count()).toList()); + var blocks = List.of(Items.IRON_BLOCK, Items.GOLD_BLOCK, Items.DIAMOND_BLOCK, + Items.EMERALD_BLOCK, Items.NETHERITE_BLOCK); + for (int i = 0; i < blocks.size(); i++) { + assertTrue(recipe.inputs().get(i + 1).ingredient().test(blocks.get(i).getDefaultInstance())); + } + assertEquals(1, recipe.outputs().size()); + assertTrue(recipe.outputs().get(0).is(Items.NETHERITE_INGOT)); + assertEquals(1, recipe.outputs().get(0).getCount()); + assertEquals(1, recipe.inputFluids().size()); + assertEquals(10_000_000L, recipe.inputFluids().get(0).amount()); + assertTrue(recipe.inputFluids().get(0).ingredient().test(new FluidStack(Fluids.LAVA, 1))); + assertEquals(10_000_000L, recipe.energy()); + assertEquals(100, recipe.processTime()); + assertEquals(AlloyFurnaceMode.NORMAL, recipe.mode()); + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); + try { + AdvancedAlloyFurnaceRecipeSerializer.INSTANCE.toNetwork(buffer, recipe); + var synced = AdvancedAlloyFurnaceRecipeSerializer.INSTANCE.fromNetwork(ID, buffer); + assertEquals(ID, synced.id()); + assertEquals(recipe.inputs().stream().map(input -> input.count()).toList(), + synced.inputs().stream().map(input -> input.count()).toList()); + assertEquals(recipe.inputFluids(), synced.inputFluids()); + assertEquals(1, synced.outputs().get(0).getCount()); + assertEquals(0, buffer.readableBytes()); + } finally { + buffer.release(); + } + } + + @Test void usefulIngotLayoutRetainsOutputAndTenMillionMbLava() throws Exception { + var recipe = generatedRecipe(); + var slots = layout(recipe); + var lava = slots.stream().filter(slot -> slot.y == 55 && slot.role == RecipeIngredientRole.INPUT) + .findFirst().orElseThrow().builder; + verify(lava).setFluidRenderer(10_000_000L, false, 69, 16); + var fluid = ArgumentCaptor.forClass(FluidStack.class); + verify(lava).addIngredient(eq(ForgeTypes.FLUID_STACK), fluid.capture()); + assertEquals(Fluids.LAVA, fluid.getValue().getFluid()); + assertEquals(10_000_000, fluid.getValue().getAmount()); + assertEquals(6, slots.stream().filter(slot -> slot.y < 55 && slot.role == RecipeIngredientRole.INPUT).count()); + var output = slots.stream().filter(slot -> slot.role == RecipeIngredientRole.OUTPUT).findFirst().orElseThrow(); + verify(output.builder).addItemStack(recipe.outputs().get(0)); + } + + @Test void fluidAlternativesAndOutputsKeepNbtAndLongAmountTooltip() { + var result = new FluidStack(Fluids.WATER, 2000); + result.getOrCreateTag().putInt("grade", 7); + var input = new LongSizedFluidIngredient(FluidIngredient.any(List.of( + FluidIngredient.single(Fluids.LAVA), FluidIngredient.nbt(true, result))), 5_000_000_000L); + var recipe = new AdvancedAlloyFurnaceRecipe(ID, List.of(), List.of(input), List.of(), + List.of(), List.of(result), List.of(), 10_000_000L, 100, Ingredient.EMPTY, 0, + List.of(), AlloyFurnaceMode.NORMAL, AdvancedAlloyFurnaceRecipe.NO_EXPLICIT_TIER); + var slots = layout(recipe); + var inputSlot = slots.stream().filter(slot -> slot.role == RecipeIngredientRole.INPUT).findFirst().orElseThrow().builder; + verify(inputSlot).setFluidRenderer((long) Integer.MAX_VALUE, false, 69, 16); + var alternatives = ArgumentCaptor.forClass(FluidStack.class); + verify(inputSlot, times(2)).addIngredient(eq(ForgeTypes.FLUID_STACK), alternatives.capture()); + assertEquals(List.of(Fluids.LAVA, Fluids.WATER), alternatives.getAllValues().stream().map(FluidStack::getFluid).toList()); + assertTrue(alternatives.getAllValues().stream().allMatch(stack -> stack.getAmount() == Integer.MAX_VALUE)); + assertEquals(7, alternatives.getAllValues().get(1).getTag().getInt("grade")); + assertEquals(5_000_000_000L, recipe.inputFluids().get(0).amount()); + var callback = ArgumentCaptor.forClass(IRecipeSlotRichTooltipCallback.class); + verify(inputSlot).addRichTooltipCallback(callback.capture()); + var tooltip = mock(ITooltipBuilder.class); + callback.getValue().onRichTooltip(mock(IRecipeSlotView.class), tooltip); + var text = ArgumentCaptor.forClass(Component.class); + verify(tooltip).add(text.capture()); + var translation = assertInstanceOf(TranslatableContents.class, text.getValue().getContents()); + assertEquals("jei.useless_mod.tooltip.amount.mb", translation.getKey()); + assertArrayEquals(new Object[] {"5.00G"}, translation.getArgs()); + var outputSlot = slots.stream().filter(slot -> slot.role == RecipeIngredientRole.OUTPUT).findFirst().orElseThrow().builder; + var displayed = ArgumentCaptor.forClass(FluidStack.class); + verify(outputSlot).addIngredient(eq(ForgeTypes.FLUID_STACK), displayed.capture()); + assertEquals(2000, displayed.getValue().getAmount()); + assertEquals(7, displayed.getValue().getTag().getInt("grade")); + assertNotSame(result, displayed.getValue()); + displayed.getValue().getOrCreateTag().putInt("grade", 9); + assertEquals(7, result.getTag().getInt("grade")); + } + + private static List layout(AdvancedAlloyFurnaceRecipe recipe) { + var slots = new ArrayList(); + var builder = mock(IRecipeLayoutBuilder.class); + when(builder.addSlot(any(), anyInt(), anyInt())).thenAnswer(call -> { + var slot = mock(IRecipeSlotBuilder.class, RETURNS_SELF); + slots.add(new Slot(call.getArgument(0), call.getArgument(2), slot)); + return slot; + }); + // Exercise the real setRecipe without constructing a Minecraft window or mod block icon. + var category = mock(AdvancedAlloyFurnaceRecipeCategory.class, CALLS_REAL_METHODS); + var entry = new AlloyFurnaceRecipeCatalog.Entry(new AlloyFurnaceRecipeIdentity(ID, "layout-check"), recipe, "core"); + try (var catalyst = mockStatic(CatalystParallelManager.class)) { + assertDoesNotThrow(() -> category.setRecipe(builder, entry, mock(IFocusGroup.class))); + } + return slots; + } + + private record Slot(RecipeIngredientRole role, int y, IRecipeSlotBuilder builder) { } +} diff --git a/src/test/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapterTest.java b/src/test/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapterTest.java new file mode 100644 index 00000000..86c077a6 --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/content/recipe/adapters/ae/ae2/InscriberRecipeAdapterTest.java @@ -0,0 +1,42 @@ +package com.sorrowmist.useless.content.recipe.adapters.ae.ae2; + +import appeng.recipes.handlers.InscriberProcessType; +import appeng.recipes.handlers.InscriberRecipe; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.compat.forge1201.recipe.RecipeHolder; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InscriberRecipeAdapterTest { + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + private static final InscriberRecipeAdapter ADAPTER=new InscriberRecipeAdapter() { + @Override public ItemStack getMoldItem() { return new ItemStack(Items.PISTON); } + }; + private static InscriberRecipe source(InscriberProcessType mode) { + var recipe=mock(InscriberRecipe.class); + when(recipe.getMiddleInput()).thenReturn(Ingredient.of(Items.GOLD_INGOT)); + when(recipe.getTopOptional()).thenReturn(Ingredient.of(Items.IRON_INGOT)); + when(recipe.getBottomOptional()).thenReturn(Ingredient.of(Items.DIAMOND)); + when(recipe.getResultItem()).thenReturn(new ItemStack(Items.EMERALD)); + when(recipe.getProcessType()).thenReturn(mode); + return recipe; + } + @Test void printingRequiresMachineAndBothPlatesWithoutConsumingThem() { + var converted=ADAPTER.convertAll(new RecipeHolder<>(new ResourceLocation("ae2:printing_test"),source(InscriberProcessType.INSCRIBE)),null).get(0); + assertEquals(3,converted.molds().size()); assertEquals(1,converted.inputs().size()); + assertTrue(converted.molds().get(0).test(new ItemStack(Items.PISTON))); + assertTrue(converted.molds().get(1).test(new ItemStack(Items.IRON_INGOT))); + assertTrue(converted.molds().get(2).test(new ItemStack(Items.DIAMOND))); + } + @Test void pressConsumesAllThreeInputsButKeepsMachine() { + var converted=ADAPTER.convertAll(new RecipeHolder<>(new ResourceLocation("ae2:pressing_test"),source(InscriberProcessType.PRESS)),null).get(0); + assertEquals(1,converted.molds().size()); assertEquals(3,converted.inputs().size()); + assertTrue(converted.inputs().stream().allMatch(i->i.consumed()&&i.count()==1)); + } +} diff --git a/src/test/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapterTest.java b/src/test/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapterTest.java new file mode 100644 index 00000000..9f4928cd --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/content/recipe/adapters/tconstruct/TConstructRecipeAdapterTest.java @@ -0,0 +1,61 @@ +package com.sorrowmist.useless.content.recipe.adapters.tconstruct; + +import com.google.gson.JsonParser; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.content.recipe.CountedIngredient; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.level.material.Fluids; +import net.minecraftforge.common.crafting.StrictNBTIngredient; +import net.minecraftforge.fluids.FluidStack; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import static org.junit.jupiter.api.Assertions.*; + +class TConstructRecipeAdapterTest { + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + @Test void reusableCastIsAnAdditionalAndMoldNeverAConsumedInput() { + var items=new ArrayList(); + var molds=new ArrayList(); + molds.add(Ingredient.of(Items.CAULDRON)); + var stack=new ItemStack(Items.GOLD_INGOT); stack.getOrCreateTag().putString("cast","wire"); + var cast=StrictNBTIngredient.of(stack); + TConstructRecipeAdapter.addCast(cast,false,items,molds); + assertTrue(items.isEmpty()); assertEquals(2,molds.size()); assertSame(cast,molds.get(1)); + assertTrue(molds.get(1).test(stack)); assertFalse(molds.get(1).test(new ItemStack(Items.GOLD_INGOT))); + } + @Test void sandAndOtherConsumedCastsAreChargedExactlyOnce() { + var items=new ArrayList(); var molds=new ArrayList(); + var cast=Ingredient.of(Items.SAND); + TConstructRecipeAdapter.addCast(cast,true,items,molds); + assertEquals(1,items.size()); assertEquals(1,items.get(0).count()); + assertTrue(items.get(0).consumed()); assertTrue(molds.isEmpty()); assertSame(cast,items.get(0).ingredient()); + } + @Test void compoundsWithDifferentCostsAreSeparateNotCheaperAlternatives() { + var choices=TConstructRecipeAdapter.fluids(JsonParser.parseString("[{\"fluid\":\"minecraft:water\",\"amount\":90},{\"fluid\":\"minecraft:lava\",\"amount\":180}]")); + assertEquals(2,choices.size()); assertEquals(90,choices.get(0).amount()); assertEquals(180,choices.get(1).amount()); + assertTrue(choices.get(0).ingredient().test(new FluidStack(Fluids.WATER,1))); + assertFalse(choices.get(0).ingredient().test(new FluidStack(Fluids.FLOWING_WATER,1))); + assertFalse(choices.get(0).ingredient().test(new FluidStack(Fluids.LAVA,1))); + } + @Test void overlappingAlternativesCannotOfferTheSameFluidAtACheaperCost() { + assertThrows(IllegalArgumentException.class,()->TConstructRecipeAdapter.fluids(JsonParser.parseString( + "[{\"fluid\":\"minecraft:water\",\"amount\":180},{\"fluid\":\"minecraft:water\",\"amount\":90}]"))); + } + @Test void fluidTagsRemainStructuralAcrossSerialization() { + var fluid=TConstructRecipeAdapter.fluids(JsonParser.parseString("{\"tag\":\"forge:molten_iron\",\"amount\":90}")).get(0); + assertEquals(90,fluid.amount()); assertEquals("forge:molten_iron",fluid.ingredient().toJson().getAsJsonObject().get("tag").getAsString()); + } + @Test void neverFlattenDurabilityMaterialsRetexturingOrFuelIntoFixedOutputs() { + var root="slimeknights.tconstruct.library.recipe."; + assertTrue(TConstructRecipeAdapter.safeClass(root+"melting.OreMeltingRecipe")); + assertTrue(TConstructRecipeAdapter.safeClass(root+"casting.ItemCastingRecipe")); + assertFalse(TConstructRecipeAdapter.safeClass(root+"melting.DamageableMeltingRecipe")); + assertFalse(TConstructRecipeAdapter.safeClass(root+"melting.MaterialMeltingRecipe")); + assertFalse(TConstructRecipeAdapter.safeClass(root+"casting.RetexturedCastingRecipe")); + assertFalse(TConstructRecipeAdapter.safeClass(root+"casting.material.MaterialCastingRecipe")); + assertFalse(TConstructRecipeAdapter.safeClass(root+"melting.MeltingFuel")); + } +} From 6d189a0a79d6d856c16f008b5b383ddea1967fea Mon Sep 17 00:00:00 2001 From: AyaYumi Date: Mon, 26 Oct 2026 00:53:36 +0800 Subject: [PATCH 2/2] fix(1.20.1): preserve native chemical quantities and typed tooltips --- README.md | 4 + .../compat/forge1201/jei/AEKeyEmiStack.java | 60 ++++- .../forge1201/jei/AEKeyEmiStackConverter.java | 38 +++ .../GenericStackJeiIngredientProviders.java | 21 ++ .../useless/mixin/emi/JemiAEKeyMixin.java | 23 +- .../mixin/emi/JemiAEKeyRegistrationMixin.java | 2 + .../jei/NativeChemicalEmiTooltipTest.java | 218 +++++++++++++++++ .../jei/NativeChemicalEmiTransferTest.java | 225 ++++++++++++++++++ 8 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStackConverter.java create mode 100644 src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTooltipTest.java create mode 100644 src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTransferTest.java diff --git a/README.md b/README.md index 14c449f7..9b287f93 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Build with `./gradlew build` (Windows: `gradlew.bat build`); Python 3 is used by release-bytecode and outline regression checks. Dependencies resolve from public Maven repositories. The `portBridgeTest` suite also runs as part of `check` and is not shipped in the mod jar. +The EMI/JEMI bridge preserves custom AE keys and long quantities when filling native +processing patterns, including Applied Mekanistics chemicals. Native JEI types and +current quantities are retained for chemical tooltips and ghost dragging. + Forge, Minecraft and AE2 are required. Every compiled mod integration declares a minimum and compatible version range in `src/main/resources/META-INF/mods.toml`; these integrations remain optional, so an absent integration does not prevent startup. diff --git a/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStack.java b/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStack.java index 1e937171..7fd8331a 100644 --- a/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStack.java +++ b/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStack.java @@ -7,11 +7,17 @@ import dev.emi.emi.api.stack.*; import dev.emi.emi.api.stack.serializer.EmiIngredientSerializer; import dev.emi.emi.jemi.*; +import mezz.jei.api.constants.VanillaTypes; +import mezz.jei.api.forge.ForgeTypes; +import mezz.jei.api.ingredients.IIngredientRenderer; +import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.ingredients.ITypedIngredient; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.nbt.*; import net.minecraft.network.chat.Component; import net.minecraft.world.item.*; import java.util.List; +import java.util.Optional; /** Scoped JEMI bridge: native recipe identity, full AE key persistence and long quantities. * No EMI entrypoint: JEMI must continue importing this mod's JEI recipe categories. @@ -21,12 +27,35 @@ public final class AEKeyEmiStack extends JemiStack EmiStack nativeStack(IIngredientType type, Object value) { + if (type == VanillaTypes.ITEM_STACK || type == ForgeTypes.FLUID_STACK) + return JemiUtil.getStack(type, value); + if (JemiPlugin.runtime == null) return null; + var manager = JemiPlugin.runtime.getIngredientManager(); + // Native custom ingredients are also bridged by JemiUtil now. Construct the identity + // view directly rather than recursively converting the same chemical into another AE key. + return new JemiStack<>(type, manager.getIngredientHelper(type), manager.getIngredientRenderer(type), + type.getIngredientClass().cast(value)); + } + /** Preserve native JEI ghost-drag/filter semantics after EMI changes a stack's amount. */ + public Optional> nativeTypedIngredient() { + if (amount <= 0 || JemiPlugin.runtime == null + || ingredient.key() instanceof AEItemKey || ingredient.key() instanceof AEFluidKey) + return Optional.empty(); + var converted = GenericStackJeiIngredientProviders.resolveNative(new GenericStack(ingredient.key(), amount)); + return converted == null ? Optional.empty() : nativeTypedIngredient(converted.type(), converted.value()); + } + private static Optional> nativeTypedIngredient(IIngredientType type, Object value) { + return JemiPlugin.runtime.getIngredientManager() + .createTypedIngredient(type, type.getIngredientClass().cast(value)).map(typed -> typed); + } @Override public AEKeyEmiStack copy() { var copy = new AEKeyEmiStack(new AEKeyJeiIngredients.KeyIngredient(ingredient.key(), Math.max(1, amount))); copy.amount = amount; + copy.renderer = renderer; copy.chance = chance; copy.comparison = comparison; copy.setRemainder(getRemainder()); @@ -39,15 +68,32 @@ public AEKeyEmiStack(AEKeyJeiIngredients.KeyIngredient value) { return ItemStack.EMPTY; } @Override public List getTooltipText() { - return AEKeyJeiIngredients.RENDERER.getTooltip( - new AEKeyJeiIngredients.KeyIngredient(ingredient.key(), Math.max(1, amount)), TooltipFlag.NORMAL); + return tooltipView().getTooltipText(); } @Override public List getTooltip() { - // JEMI renders the immutable ingredient; rebuild its view after EMI changes the amount. - return amount == ingredient.amount() ? originalTooltip() : copy().originalTooltip(); + var view = tooltipView(); + view.setAmount(amount).setChance(chance).setRemainder(getRemainder()); + return view.getTooltip(); + } + private JemiStack tooltipView() { + var value = new AEKeyJeiIngredients.KeyIngredient(ingredient.key(), Math.max(1, amount)); + if (renderer != AEKeyJeiIngredients.RENDERER && amount > 0 && JemiPlugin.runtime != null) { + // Use the same typed ingredient that JemiSlotWidget uses to select its slot renderer. + // A native renderer must never receive our KeyIngredient wrapper (or vice versa). + var typed = JemiUtil.getTyped(this).orElse(null); + if (typed != null) { + if (typed.getType() != AEKeyJeiIngredients.TYPE) return nativeTooltipView(typed); + return new JemiStack<>(AEKeyJeiIngredients.TYPE, AEKeyJeiIngredients.HELPER, renderer, value); + } + } + return new JemiStack<>(AEKeyJeiIngredients.TYPE, AEKeyJeiIngredients.HELPER, + AEKeyJeiIngredients.RENDERER, value); } - private List originalTooltip() { - return super.getTooltip(); + @SuppressWarnings("unchecked") + private JemiStack nativeTooltipView(ITypedIngredient typed) { + return new JemiStack<>(typed.getType(), + JemiPlugin.runtime.getIngredientManager().getIngredientHelper(typed.getType()), + (IIngredientRenderer) (IIngredientRenderer) renderer, typed.getIngredient()); } @Override public void render(GuiGraphics gui, int x, int y, float delta, int flags) { if ((flags & 1) != 0) { diff --git a/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStackConverter.java b/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStackConverter.java new file mode 100644 index 00000000..2009fdda --- /dev/null +++ b/src/main/java/com/sorrowmist/useless/compat/forge1201/jei/AEKeyEmiStackConverter.java @@ -0,0 +1,38 @@ +package com.sorrowmist.useless.compat.forge1201.jei; + +import appeng.api.integrations.emi.EmiStackConverter; +import appeng.api.integrations.emi.EmiStackConverters; +import appeng.api.stacks.AEFluidKey; +import appeng.api.stacks.AEItemKey; +import appeng.api.stacks.GenericStack; +import com.mojang.logging.LogUtils; +import com.sorrowmist.useless.compat.jei.AEKeyJeiIngredients; +import com.sorrowmist.useless.utils.RateLimitedLogger; +import dev.emi.emi.api.stack.EmiStack; +import org.jetbrains.annotations.Nullable; + +/** Connects JEMI custom ingredients to AE2's native fill, drag/drop and inventory paths. */ +public final class AEKeyEmiStackConverter implements EmiStackConverter { + public static void register() { + if (EmiStackConverters.getConverters().stream().anyMatch(AEKeyEmiStackConverter.class::isInstance)) return; + if (!EmiStackConverters.register(new AEKeyEmiStackConverter())) { + RateLimitedLogger.warn(LogUtils.getLogger(), "Could not register the JEMI AE-key stack converter"); + } + } + + @Override public Class getKeyType() { + // Custom JemiStack keys are their JEI UID strings; items/fluids keep AE2's converters. + return String.class; + } + + @Override public @Nullable EmiStack toEmiStack(GenericStack stack) { + if (stack == null || stack.what() == null || stack.amount() <= 0 + || stack.what() instanceof AEItemKey || stack.what() instanceof AEFluidKey) return null; + return new AEKeyEmiStack(new AEKeyJeiIngredients.KeyIngredient(stack.what(), stack.amount())); + } + + @Override public @Nullable GenericStack toGenericStack(EmiStack stack) { + if (!(stack instanceof AEKeyEmiStack key) || stack.getAmount() <= 0) return null; + return new GenericStack(key.ingredient.key(), stack.getAmount()); + } +} diff --git a/src/main/java/com/sorrowmist/useless/compat/jei/GenericStackJeiIngredientProviders.java b/src/main/java/com/sorrowmist/useless/compat/jei/GenericStackJeiIngredientProviders.java index 5a0e11fb..eec5ec92 100644 --- a/src/main/java/com/sorrowmist/useless/compat/jei/GenericStackJeiIngredientProviders.java +++ b/src/main/java/com/sorrowmist/useless/compat/jei/GenericStackJeiIngredientProviders.java @@ -9,6 +9,7 @@ import appeng.integration.modules.jei.FluidIngredientConverter; import com.mojang.logging.LogUtils; import mezz.jei.api.constants.VanillaTypes; +import mezz.jei.api.ingredients.IIngredientType; import net.minecraft.world.item.ItemStack; import net.minecraftforge.fml.ModList; import org.jetbrains.annotations.Nullable; @@ -65,6 +66,26 @@ public static synchronized void initialize() { return null; } + /** Read the live addon converters: AppMek installs its four types at JEI runtime creation. */ + public static @Nullable GenericStack fromJei(IIngredientType type, Object ingredient) { + if (type == null || ingredient == null || !type.getIngredientClass().isInstance(ingredient)) return null; + for (var converter : IngredientConverters.getConverters()) { + var stack = fromJei(type, ingredient, converter); + if (valid(stack)) return stack; + } + return null; + } + + private static @Nullable GenericStack fromJei( + IIngredientType type, Object ingredient, IngredientConverter converter) { + try { + if (converter.getIngredientType() != type) return null; + return converter.getStackFromIngredient(converter.getIngredientType().getIngredientClass().cast(ingredient)); + } catch (RuntimeException | LinkageError exception) { + return null; + } + } + private static boolean valid(GenericStack stack) { return stack != null && stack.what() != null && stack.amount() > 0; } diff --git a/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyMixin.java b/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyMixin.java index 5fd52693..544faffd 100644 --- a/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyMixin.java +++ b/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyMixin.java @@ -1,9 +1,14 @@ package com.sorrowmist.useless.mixin.emi; +import appeng.api.stacks.AEFluidKey; +import appeng.api.stacks.AEItemKey; import com.sorrowmist.useless.compat.jei.AEKeyJeiIngredients; +import com.sorrowmist.useless.compat.jei.GenericStackJeiIngredientProviders; import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStack; import dev.emi.emi.api.stack.EmiStack; import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.ingredients.ITypedIngredient; +import java.util.Optional; import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.*; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @@ -14,7 +19,23 @@ public abstract class JemiAEKeyMixin { @Inject(method = "getStack(Lmezz/jei/api/ingredients/IIngredientType;Ljava/lang/Object;)Ldev/emi/emi/api/stack/EmiStack;", at = @At("HEAD"), cancellable = true, require = 1) private static void useless$aeKey(IIngredientType type, Object ingredient, CallbackInfoReturnable cir) { - if (type == AEKeyJeiIngredients.TYPE && ingredient instanceof AEKeyJeiIngredients.KeyIngredient key) + if (type == AEKeyJeiIngredients.TYPE && ingredient instanceof AEKeyJeiIngredients.KeyIngredient key) { cir.setReturnValue(new AEKeyEmiStack(key)); + return; + } + var stack = GenericStackJeiIngredientProviders.fromJei(type, ingredient); + if (stack != null && !(stack.what() instanceof AEItemKey) && !(stack.what() instanceof AEFluidKey)) { + // Carry the converter's quantity rather than a custom JEMI stack's default amount. + // AE2 reads EmiIngredient.getAmount() separately when it constructs processing inputs. + cir.setReturnValue(new AEKeyEmiStack(new AEKeyJeiIngredients.KeyIngredient(stack.what(), stack.amount()))); + } + } + + @Inject(method = "getTyped(Ldev/emi/emi/api/stack/EmiStack;)Ljava/util/Optional;", + at = @At("HEAD"), cancellable = true, require = 1) + private static void useless$nativeTyped(EmiStack stack, CallbackInfoReturnable>> cir) { + if (stack instanceof AEKeyEmiStack keyStack) { + keyStack.nativeTypedIngredient().ifPresent(typed -> cir.setReturnValue(Optional.of(typed))); + } } } diff --git a/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyRegistrationMixin.java b/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyRegistrationMixin.java index 1fd8b6f0..9f0b53d8 100644 --- a/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyRegistrationMixin.java +++ b/src/main/java/com/sorrowmist/useless/mixin/emi/JemiAEKeyRegistrationMixin.java @@ -1,6 +1,7 @@ package com.sorrowmist.useless.mixin.emi; import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStack; +import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStackConverter; import dev.emi.emi.api.EmiRegistry; import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.*; @@ -12,5 +13,6 @@ public abstract class JemiAEKeyRegistrationMixin { @Inject(method = "register", at = @At("RETURN"), require = 1) private void useless$aeKeySerializer(EmiRegistry registry, CallbackInfo ci) { AEKeyEmiStack.register(registry); + AEKeyEmiStackConverter.register(); } } diff --git a/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTooltipTest.java b/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTooltipTest.java new file mode 100644 index 00000000..ffbe6a43 --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTooltipTest.java @@ -0,0 +1,218 @@ +package com.sorrowmist.useless.compat.jei; + +import appeng.api.integrations.jei.IngredientConverter; +import appeng.api.integrations.jei.IngredientConverters; +import appeng.api.stacks.AEKey; +import appeng.api.stacks.AEKeyType; +import appeng.api.stacks.GenericStack; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStack; +import dev.emi.emi.api.stack.EmiStack; +import dev.emi.emi.jemi.JemiPlugin; +import dev.emi.emi.jemi.JemiStack; +import dev.emi.emi.jemi.JemiUtil; +import dev.emi.emi.jemi.impl.JemiRecipeSlot; +import dev.emi.emi.jemi.impl.JemiTooltipBuilder; +import dev.emi.emi.jemi.widget.JemiSlotWidget; +import mezz.jei.api.gui.builder.ITooltipBuilder; +import mezz.jei.api.ingredients.IIngredientHelper; +import mezz.jei.api.ingredients.IIngredientRenderer; +import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.ingredients.ITypedIngredient; +import mezz.jei.api.recipe.RecipeIngredientRole; +import mezz.jei.api.runtime.IIngredientManager; +import mezz.jei.api.runtime.IJeiRuntime; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.TooltipFlag; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class NativeChemicalEmiTooltipTest { + private record NativeChemical(long amount) { } + private static final IIngredientType TYPE = () -> NativeChemical.class; + + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + + @Test void hoveringNativeChemicalSlotUsesItsRendererWithANativeIngredient() throws Exception { + try (var fixture = new Fixture()) { + var stack = spy(fixture.stack(80)); + var copy = spy(stack.copy()); + doReturn(copy).when(stack).copy(); + // Execute JEMI's real copy-and-replace-renderer path. Only final presentation is + // isolated, since JemiStack.getTooltip initializes GLFW keybindings in a unit JVM. + doAnswer(i -> richTooltip(copy, TooltipFlag.NORMAL)).when(copy).getTooltip(); + var slot = new JemiRecipeSlot(RecipeIngredientRole.OUTPUT, stack); + var tooltip = new ArrayList(); + JemiSlotWidget.addTooltip(tooltip, slot, stack, fixture.renderer); + assertFalse(tooltip.isEmpty()); + assertEquals(List.of(80L), fixture.renderer.richAmounts); + assertEquals(80, stack.getAmount()); + assertSame(AEKeyJeiIngredients.RENDERER, stack.renderer); + } + } + + @Test void overriddenRendererSurvivesCopiesAndUsesTheCurrentLongAmount() throws Exception { + try (var fixture = new Fixture()) { + var stack = fixture.stack(80); + replaceRenderer(stack, fixture.renderer); + var edited = (AEKeyEmiStack) stack.copy().setAmount(4_500_000_080L); + assertFalse(richTooltip(edited, TooltipFlag.ADVANCED).isEmpty()); + assertEquals(List.of(Component.literal("chemical:4500000080")), edited.getTooltipText()); + assertSame(fixture.renderer, edited.renderer); + assertFalse(richTooltip(stack, TooltipFlag.ADVANCED).isEmpty()); + assertEquals(List.of(4_500_000_080L, 80L), fixture.renderer.richAmounts); + assertEquals(List.of(true, true), fixture.renderer.advancedFlags); + assertEquals(80, stack.getAmount()); + } + } + + @Test void fallbackKeySlotKeepsItsOwnRendererAndCurrentAmount() throws Exception { + try (var fixture = new Fixture()) { + fixture.converters.when(IngredientConverters::getConverters).thenReturn(List.of()); + var stack = fixture.stack(80); + var renderer = keyRenderer(); + stack.renderer = renderer; + var edited = (AEKeyEmiStack) stack.copy().setAmount(160); + assertEquals(List.of(Component.literal("key:160")), edited.getTooltipText()); + assertFalse(richTooltip(edited, TooltipFlag.NORMAL).isEmpty()); + assertSame(renderer, edited.renderer); + } + } + + @Test void keyTypedSlotRendererIsNotGivenANativeValueJustBecauseAConverterExists() throws Exception { + try (var fixture = new Fixture()) { + fixture.keyTyping = true; + var stack = fixture.stack(80); + stack.renderer = keyRenderer(); + assertEquals(List.of(Component.literal("key:80")), stack.getTooltipText()); + assertFalse(richTooltip(stack, TooltipFlag.NORMAL).isEmpty()); + } + } + + @Test void missingRuntimeDoesNotPassAKeyToTheOldNativeRenderer() throws Exception { + try (var fixture = new Fixture()) { + var stack = fixture.stack(80); + replaceRenderer(stack, fixture.renderer); + JemiPlugin.runtime = null; + assertFalse(richTooltip(stack, TooltipFlag.NORMAL).isEmpty()); + assertTrue(fixture.renderer.richAmounts.isEmpty()); + } + } + + private static IIngredientRenderer keyRenderer() { + return new IIngredientRenderer<>() { + @Override public void render(GuiGraphics gui, AEKeyJeiIngredients.KeyIngredient value) { } + @Override public List getTooltip(AEKeyJeiIngredients.KeyIngredient value, TooltipFlag flag) { + return List.of(Component.literal("key:" + value.amount())); + } + }; + } + + @SuppressWarnings("unchecked") + private static ITypedIngredient typed(IIngredientType type, T value) { + var typed = (ITypedIngredient) mock(ITypedIngredient.class); + when(typed.getType()).thenReturn(type); + when(typed.getIngredient()).thenReturn(value); + return typed; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void replaceRenderer(JemiStack stack, IIngredientRenderer renderer) { + ((JemiStack) stack).renderer = renderer; + } + + @SuppressWarnings({"rawtypes", "unchecked", "removal"}) + private static List richTooltip(AEKeyEmiStack stack, TooltipFlag flag) throws Exception { + var method = AEKeyEmiStack.class.getDeclaredMethod("tooltipView"); + method.setAccessible(true); + JemiStack view = (JemiStack) method.invoke(stack); + var builder = new JemiTooltipBuilder(); + // Preserve JEMI's rich renderer overload, including the compiler-generated type bridge. + view.renderer.getTooltip(builder, view.ingredient, flag); + return builder.tooltip; + } + + /** A real generic renderer: its compiler-generated bridge rejects KeyIngredient like Mekanism's. */ + private static final class ChemicalRenderer implements IIngredientRenderer { + final List richAmounts = new ArrayList<>(); + final List advancedFlags = new ArrayList<>(); + @Override public void render(GuiGraphics gui, NativeChemical value) { } + @Override public List getTooltip(NativeChemical value, TooltipFlag flag) { + return List.of(Component.literal("chemical:" + value.amount())); + } + @Override public void getTooltip(ITooltipBuilder builder, NativeChemical value, TooltipFlag flag) { + richAmounts.add(value.amount()); + advancedFlags.add(flag.isAdvanced()); + builder.add(Component.literal("chemical:" + value.amount())); + } + } + + private static final class Fixture implements AutoCloseable { + final IJeiRuntime previousRuntime = JemiPlugin.runtime; + final AEKey key = mock(AEKey.class); + final ChemicalRenderer renderer = new ChemicalRenderer(); + final MockedStatic converters; + final MockedStatic jemi; + boolean keyTyping; + + @SuppressWarnings("unchecked") + Fixture() { + var tag = new CompoundTag(); + tag.putString("chemical", "test:carbon"); + when(key.toTagGeneric()).thenReturn(tag); + when(key.getType()).thenReturn(mock(AEKeyType.class)); + when(key.getDisplayName()).thenReturn(Component.literal("Carbon")); + when(key.formatAmount(anyLong(), any())).thenAnswer(i -> i.getArgument(0) + " mB"); + var runtime = mock(IJeiRuntime.class); + var manager = mock(IIngredientManager.class); + var helper = (IIngredientHelper) mock(IIngredientHelper.class); + when(helper.getUniqueId(any(NativeChemical.class), any())).thenReturn("infusion:test:carbon"); + when(helper.isValidIngredient(any(NativeChemical.class))).thenReturn(true); + when(runtime.getIngredientManager()).thenReturn(manager); + when(manager.getIngredientHelper(TYPE)).thenReturn(helper); + when(manager.getIngredientRenderer(TYPE)).thenReturn(renderer); + when(manager.createTypedIngredient(eq(TYPE), any(NativeChemical.class))) + .thenAnswer(i -> Optional.of(typed(TYPE, i.getArgument(1)))); + JemiPlugin.runtime = runtime; + converters = mockStatic(IngredientConverters.class); + converters.when(IngredientConverters::getConverters).thenReturn(List.of(new IngredientConverter() { + @Override public IIngredientType getIngredientType() { return TYPE; } + @Override public NativeChemical getIngredientFromStack(GenericStack stack) { + return stack.what() == key ? new NativeChemical(stack.amount()) : null; + } + @Override public GenericStack getStackFromIngredient(NativeChemical value) { + return new GenericStack(key, value.amount()); + } + })); + // The mixin is not installed in the unit JVM; emulate its getTyped dispatch only. + jemi = mockStatic(JemiUtil.class); + jemi.when(() -> JemiUtil.getTyped(any(EmiStack.class))).thenAnswer(i -> { + var stack = (AEKeyEmiStack) i.getArgument(0); + var nativeTyped = keyTyping ? Optional.>empty() : stack.nativeTypedIngredient(); + return nativeTyped.isPresent() ? nativeTyped : Optional.of(typed(AEKeyJeiIngredients.TYPE, + new AEKeyJeiIngredients.KeyIngredient(key, Math.max(1, stack.getAmount())))); + }); + } + + AEKeyEmiStack stack(long amount) { + return new AEKeyEmiStack(new AEKeyJeiIngredients.KeyIngredient(key, amount)); + } + + @Override public void close() { + JemiPlugin.runtime = previousRuntime; + jemi.close(); + converters.close(); + } + } +} diff --git a/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTransferTest.java b/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTransferTest.java new file mode 100644 index 00000000..1baffff3 --- /dev/null +++ b/src/test/java/com/sorrowmist/useless/compat/jei/NativeChemicalEmiTransferTest.java @@ -0,0 +1,225 @@ +package com.sorrowmist.useless.compat.jei; + +import appeng.api.integrations.emi.EmiStackConverters; +import appeng.api.integrations.jei.IngredientConverter; +import appeng.api.integrations.jei.IngredientConverters; +import appeng.api.stacks.AEKey; +import appeng.api.stacks.AEItemKey; +import appeng.api.stacks.AEFluidKey; +import appeng.api.stacks.GenericStack; +import appeng.integration.modules.emi.EmiStackHelper; +import com.sorrowmist.useless.InventoryTestBootstrap; +import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStack; +import com.sorrowmist.useless.compat.forge1201.jei.AEKeyEmiStackConverter; +import com.sorrowmist.useless.mixin.emi.JemiAEKeyMixin; +import dev.emi.emi.api.recipe.EmiRecipe; +import dev.emi.emi.api.stack.EmiStack; +import dev.emi.emi.jemi.JemiPlugin; +import dev.emi.emi.jemi.JemiUtil; +import mezz.jei.api.ingredients.IIngredientHelper; +import mezz.jei.api.ingredients.IIngredientRenderer; +import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.ingredients.ITypedIngredient; +import mezz.jei.api.runtime.IIngredientManager; +import mezz.jei.api.runtime.IJeiRuntime; +import net.minecraft.nbt.CompoundTag; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class NativeChemicalEmiTransferTest { + private record NativeChemical(long amount) { } + private static final IIngredientType TYPE = () -> NativeChemical.class; + + @BeforeAll static void bootstrap() { InventoryTestBootstrap.initialize(); } + + @Test void nativeEnrichedCarbonOutputAndChemicalInputKeepEightyNotOne() throws Exception { + AEKey key = key("carbon"); + var converter = converter(TYPE, key); + try (var jei = mockStatic(IngredientConverters.class); + var emi = mockStatic(EmiStackConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter)); + emi.when(EmiStackConverters::getConverters).thenReturn(List.of(new AEKeyEmiStackConverter())); + var stack = bridge(TYPE, new NativeChemical(80)); + assertInstanceOf(AEKeyEmiStack.class, stack); + assertEquals(80, stack.getAmount()); + assertEquals(new GenericStack(key, 80), EmiStackHelper.toGenericStack(stack)); + var recipe = mock(EmiRecipe.class); + when(recipe.getInputs()).thenReturn(List.of(stack)); + when(recipe.getOutputs()).thenReturn(List.of(stack)); + // AE2 overwrites converted input quantities with EmiIngredient.getAmount(). + // Fixing only its stack converter would still turn an 80 mB input into 1 mB. + assertEquals(List.of(List.of(new GenericStack(key, 80))), EmiStackHelper.ofInputs(recipe)); + assertEquals(List.of(new GenericStack(key, 80)), EmiStackHelper.ofOutputs(recipe)); + } + } + + @Test void copiesAndEditedAmountsAreNotMultipliedByOriginalChemicalQuantity() throws Exception { + AEKey key = key("carbon"); + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(TYPE, key))); + var stack = (AEKeyEmiStack) bridge(TYPE, new NativeChemical(80)); + var edited = stack.copy().setAmount(160); + var converter = new AEKeyEmiStackConverter(); + assertEquals(new GenericStack(key, 160), converter.toGenericStack(edited)); + assertEquals(new GenericStack(key, 80), converter.toGenericStack(stack)); + assertEquals(160, edited.copy().getAmount()); + edited.setAmount(0); + assertNull(converter.toGenericStack(edited)); + } + } + + @Test void allFourChemicalFormsAndLongAmountsUseLiveJeiConverters() throws Exception { + for (String form : List.of("gas", "infusion", "pigment", "slurry")) { + AEKey key = key(form); + long amount = 4_500_000_080L; + IIngredientType type = new IIngredientType<>() { + @Override public Class getIngredientClass() { return NativeChemical.class; } + }; + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(type, key))); + assertEquals(new GenericStack(key, amount), + GenericStackJeiIngredientProviders.fromJei(type, new NativeChemical(amount))); + var stack = bridge(type, new NativeChemical(amount)); + assertEquals(amount, stack.getAmount()); + assertEquals(new GenericStack(key, amount), new AEKeyEmiStackConverter().toGenericStack(stack)); + } + } + } + + @Test void nativeIdentityViewDoesNotReenterTheJemiConversionHook() throws Exception { + AEKey key = key("carbon"); + var oldRuntime = JemiPlugin.runtime; + var runtime = mock(IJeiRuntime.class); + var manager = mock(IIngredientManager.class); + @SuppressWarnings("unchecked") var helper = (IIngredientHelper) mock(IIngredientHelper.class); + @SuppressWarnings("unchecked") var renderer = (IIngredientRenderer) mock(IIngredientRenderer.class); + when(runtime.getIngredientManager()).thenReturn(manager); + when(manager.getIngredientHelper(TYPE)).thenReturn(helper); + when(manager.getIngredientRenderer(TYPE)).thenReturn(renderer); + when(helper.getUniqueId(any(NativeChemical.class), any())).thenReturn("infusion:mekanism:carbon"); + JemiPlugin.runtime = runtime; + try (var jei = mockStatic(IngredientConverters.class); + var jemi = mockStatic(JemiUtil.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(TYPE, key))); + var stack = bridge(TYPE, new NativeChemical(80)); + assertEquals("infusion:mekanism:carbon", stack.getKey()); + assertEquals(80, stack.copy().getAmount()); + jemi.verifyNoInteractions(); + } finally { JemiPlugin.runtime = oldRuntime; } + } + + @Test void nativeGhostDragRoundTripKeepsNativeTypeAndEditedAmount() throws Exception { + AEKey key = key("carbon"); + var oldRuntime = JemiPlugin.runtime; + var runtime = mock(IJeiRuntime.class); + var manager = mock(IIngredientManager.class); + @SuppressWarnings("unchecked") var helper = (IIngredientHelper) mock(IIngredientHelper.class); + @SuppressWarnings("unchecked") var renderer = (IIngredientRenderer) mock(IIngredientRenderer.class); + when(runtime.getIngredientManager()).thenReturn(manager); + when(manager.getIngredientHelper(TYPE)).thenReturn(helper); + when(manager.getIngredientRenderer(TYPE)).thenReturn(renderer); + when(manager.createTypedIngredient(eq(TYPE), any(NativeChemical.class))).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") var typed = (ITypedIngredient) mock(ITypedIngredient.class); + when(typed.getType()).thenReturn(TYPE); + when(typed.getIngredient()).thenReturn(invocation.getArgument(1)); + return Optional.of(typed); + }); + JemiPlugin.runtime = runtime; + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(TYPE, key))); + var stack = bridge(TYPE, new NativeChemical(80)); + var original = typedBridge(stack).orElseThrow(); + assertSame(TYPE, original.getType()); + assertEquals(new NativeChemical(80), original.getIngredient()); + var edited = typedBridge(stack.copy().setAmount(160)).orElseThrow(); + assertSame(TYPE, edited.getType()); + assertEquals(new NativeChemical(160), edited.getIngredient()); + assertEquals(new NativeChemical(80), typedBridge(stack).orElseThrow().getIngredient()); + assertNull(typedBridge(stack.copy().setAmount(0))); + } finally { JemiPlugin.runtime = oldRuntime; } + } + + @Test void unsupportedOrEmptyNativeIngredientsAreNotTurnedIntoFakeItems() throws Exception { + AEKey key = key("empty"); + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of()); + assertNull(bridge(TYPE, new NativeChemical(80))); + assertNull(GenericStackJeiIngredientProviders.fromJei(TYPE, "wrong type")); + assertNull(GenericStackJeiIngredientProviders.fromJei(null, new NativeChemical(80))); + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(TYPE, key))); + assertNull(bridge(TYPE, new NativeChemical(0))); + assertNull(bridge(TYPE, new NativeChemical(-1))); + } + } + + @Test void ingredientTypesSharingAClassCannotUseEachOthersConverter() { + AEKey key = key("carbon"); + IIngredientType otherType = new IIngredientType<>() { + @Override public Class getIngredientClass() { return NativeChemical.class; } + }; + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of(converter(TYPE, key))); + assertNull(GenericStackJeiIngredientProviders.fromJei(otherType, new NativeChemical(80))); + } + } + + @Test void reverseBridgePreservesCustomKeysButLeavesItemsAndFluidsToAe2() { + var converter = new AEKeyEmiStackConverter(); + AEKey key = key("carbon"); + try (var jei = mockStatic(IngredientConverters.class)) { + jei.when(IngredientConverters::getConverters).thenReturn(List.of()); + var stack = converter.toEmiStack(new GenericStack(key, 80)); + assertInstanceOf(AEKeyEmiStack.class, stack); + assertEquals(new GenericStack(key, 80), converter.toGenericStack(stack)); + assertNull(converter.toEmiStack(new GenericStack(mock(AEItemKey.class), 1))); + assertNull(converter.toEmiStack(new GenericStack(mock(AEFluidKey.class), 1))); + assertNull(converter.toEmiStack(new GenericStack(key, 0))); + assertNull(converter.toGenericStack(mock(EmiStack.class))); + } + } + + private static AEKey key(String name) { + AEKey key = mock(AEKey.class); + var tag = new CompoundTag(); + tag.putString("chemical", "test:" + name); + when(key.toTagGeneric()).thenReturn(tag); + return key; + } + + private static IngredientConverter converter(IIngredientType type, AEKey key) { + return new IngredientConverter<>() { + @Override public IIngredientType getIngredientType() { return type; } + @Override public NativeChemical getIngredientFromStack(GenericStack stack) { + return stack.what() == key ? new NativeChemical(stack.amount()) : null; + } + @Override public GenericStack getStackFromIngredient(NativeChemical ingredient) { + return new GenericStack(key, ingredient.amount()); + } + }; + } + + private static Optional> typedBridge(EmiStack stack) throws Exception { + var callback = new CallbackInfoReturnable>>("getTyped", true); + var method = JemiAEKeyMixin.class.getDeclaredMethod("useless$nativeTyped", + EmiStack.class, CallbackInfoReturnable.class); + method.setAccessible(true); + method.invoke(null, stack, callback); + return callback.isCancelled() ? callback.getReturnValue() : null; + } + + private static EmiStack bridge(IIngredientType type, Object ingredient) throws Exception { + var callback = new CallbackInfoReturnable("getStack", true); + var method = JemiAEKeyMixin.class.getDeclaredMethod("useless$aeKey", + IIngredientType.class, Object.class, CallbackInfoReturnable.class); + method.setAccessible(true); + method.invoke(null, type, ingredient, callback); + return callback.isCancelled() ? callback.getReturnValue() : null; + } +}