diff --git a/crates/horizon-client-render/examples/brewing_fuel_probe.rs b/crates/horizon-client-render/examples/brewing_fuel_probe.rs new file mode 100644 index 0000000000..7afd912c33 --- /dev/null +++ b/crates/horizon-client-render/examples/brewing_fuel_probe.rs @@ -0,0 +1,55 @@ +//! Headless production brewing-fuel component fixture. +use horizon_client_render::{ + brewing::{Brewing, BrewingStand, brewing_fuel}, + providers::Providers, +}; +use horizon_items::{ + item_data::decode_components, + items::{ItemStack, Items}, +}; +use horizon_world::loot::PackOverlay; +use std::{ + io::{BufRead, Write}, + path::Path, +}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let root = Path::new(&args[1]); + let items = Items::catalog(root, &PackOverlay::NONE)?; + let providers = Providers::load(root); + let brewing = Brewing { + recipes: vec![], + reagents: Default::default(), + inputs: Default::default(), + }; + let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?); + for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() { + let input: serde_json::Value = serde_json::from_str(&line?)?; + let mut stack = ItemStack::new( + input["item"].as_str().unwrap(), + input["count"].as_u64().unwrap() as u32, + ); + let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]); + let (_, extra) = decode_components(Some(&components)); + stack.extra = extra; + let accepted = brewing_fuel(&items, &stack).is_some(); + let mut stand = BrewingStand::new(); + // A newly constructed vanilla block entity has no saved total yet. + stand.total_fuel = 0; + stand.items[4] = Some(stack); + for _ in 0..2 { + stand.tick(&brewing, &items, &providers); + writeln!( + out, + "{} {} {} {} {}", + accepted, + stand.fuel, + stand.total_fuel, + stand.speed.to_bits(), + stand.items[4].as_ref().map_or(0, |s| s.count) + )?; + } + } + Ok(()) +} diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index efe8b7ada0..bdd2c837f3 100644 --- a/crates/horizon-client-render/src/brewing.rs +++ b/crates/horizon-client-render/src/brewing.rs @@ -387,8 +387,21 @@ impl BrewingStand { out.brewed = true; } } -/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier). +/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier), +/// respecting component patches before falling back to the item's defaults. pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> { + if let Some(data) = stack.data() { + if data.other("!minecraft:brewing_fuel").is_some() { + return None; + } + if let Some(own) = data.other("minecraft:brewing_fuel") { + let component = horizon_client_core::scoreboard::tag_to_json(own); + return Some(( + component["uses"].clone(), + component["speed_multiplier"].clone(), + )); + } + } items.get(&stack.item).and_then(|i| i.brewing_fuel.clone()) } @@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> mod tests { use super::*; + #[test] + fn stack_fuel_controls_brew_duration_and_consumption() { + let items = Items::for_tests(Default::default()); + let brewing = Brewing { + reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + inputs: HashSet::new(), + recipes: vec![BrewingRecipe { + id: "awkward".into(), + input: PotionIngredient { + items: ["minecraft:potion".to_owned()].into_iter().collect(), + potions: Some(["minecraft:water".to_owned()].into_iter().collect()), + }, + reagent: PotionIngredient { + items: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + potions: None, + }, + output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"), + }], + }; + let mut fuel = ItemStack::new("minecraft:stone", 2); + fuel.data_mut().set_loot_component( + "minecraft:brewing_fuel", + &serde_json::json!({"uses": 7, "speed_multiplier": 2.5}), + ); + let mut stand = BrewingStand::new(); + stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")); + stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1)); + stand.items[4] = Some(fuel); + let providers = Providers::empty(); + stand.tick(&brewing, &items, &providers); + assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7)); + assert_eq!(stand.items[4].as_ref().unwrap().count, 1); + for _ in 1..160 { + assert!(!stand.tick(&brewing, &items, &providers).brewed); + } + assert!(stand.tick(&brewing, &items, &providers).brewed); + assert_eq!( + stand.items[0].as_ref().unwrap().potion(), + Some("minecraft:awkward") + ); + assert_eq!(stand.fuel, 6); + assert_eq!(stand.items[4].as_ref().unwrap().count, 1); + } + #[test] fn potion_colours_and_names() { // Swiftness: speed colour 3402751 = 0x33EBFF. diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 45fee5a271..98e1080ff5 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -331,10 +331,7 @@ impl Containers<'_> { if view.kind == Kind::Brewing { // BrewingStandBlockEntity.canPlaceItem return match slot { - 4 => self - .items - .get(&stack.item) - .is_some_and(|i| i.brewing_fuel.is_some()), + 4 => horizon_client::brewing::brewing_fuel(self.items, stack).is_some(), 3 => self.brewing.is_some_and(|b| b.is_reagent(stack)), _ => { let input = self.brewing.is_some_and(|b| b.is_bottle(stack)) @@ -1557,6 +1554,71 @@ impl Game<'_> { } } +#[cfg(test)] +mod brewing_fuel_transfer_tests { + use super::super::native_machines::tests::server_game; + use super::*; + use horizon_client::brewing::BrewingStand; + + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"] + fn hopper_brewing_fuel_transfer_respects_stack_components() { + let mut game = server_game(); + let hopper_pos = (8, 64, 8); + let stand_pos = (9, 64, 8); + for (pos, block) in [ + (hopper_pos, "minecraft:hopper"), + (stand_pos, "minecraft:brewing_stand"), + ] { + let state = game.pack.assets.registry.default_state(block).unwrap(); + game.world.set(pos.0, pos.1, pos.2, state).unwrap(); + } + let default = ItemStack::new("minecraft:blaze_powder", 2); + let mut custom = ItemStack::new("minecraft:stone", 2); + custom.data_mut().set_loot_component( + "minecraft:brewing_fuel", + &serde_json::json!({"uses": 7, "speed_multiplier": 2.5}), + ); + let mut removed = default.clone(); + removed.data_mut().set_other( + "!minecraft:brewing_fuel", + Some(horizon_world::nbt::Tag::Compound(Default::default())), + ); + for (stack, accepted) in [ + (default, true), + (custom, true), + (removed, false), + (ItemStack::new("minecraft:stone", 2), false), + ] { + game.world + .block_entities + .insert(hopper_pos, BlockEntity::Chest(vec![Some(stack); 5])); + game.world.block_entities.insert( + stand_pos, + BlockEntity::Brewing(Box::new(BrewingStand::new())), + ); + let containers = game.containers(); + let hopper = containers.view(hopper_pos, false).unwrap(); + let stand = containers.view(stand_pos, false).unwrap(); + let fuel = containers.get(&hopper, 0).unwrap(); + assert_eq!(containers.can_place(&stand, 4, fuel), accepted); + assert_eq!( + game.eject_into(hopper_pos, &hopper, &stand, Direction::West), + accepted + ); + let containers = game.containers(); + assert_eq!( + containers.get(&hopper, 0).unwrap().count, + if accepted { 1 } else { 2 } + ); + assert_eq!( + containers.get(&stand, 4).map(|s| s.count), + accepted.then_some(1) + ); + } + } +} + #[cfg(test)] mod item_column_tests { use super::ItemColumns; diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index c0eeee3804..5fcce1d3cf 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -1425,10 +1425,7 @@ impl Menu { .is_some_and(|m| m.contains(&*stack.item)) } Role::Ingredient => env.brewing.is_reagent(stack), - Role::BrewFuel => env - .items - .get(&stack.item) - .is_some_and(|i| i.brewing_fuel.is_some()), + Role::BrewFuel => crate::brewing::brewing_fuel(env.items, stack).is_some(), Role::LoomBanner => Self::is_banner(stack), Role::LoomDye => env.banners.loom_dye(env.items, stack).is_some(), Role::LoomPattern => env.banners.is_pattern_item(env.items, stack), @@ -2281,11 +2278,7 @@ impl Menu { if !self.move_to(env, &mut stack, 5, 41, true) { return None; } - } else if env - .items - .get(&clicked.item) - .is_some_and(|i| i.brewing_fuel.is_some()) - { + } else if crate::brewing::brewing_fuel(env.items, &clicked).is_some() { let moved = self.move_to(env, &mut stack, 4, 5, false); if moved || (env.brewing.is_reagent(&stack) @@ -3441,6 +3434,45 @@ pub(crate) mod tests { test(&env, &mut menu); } + /// `BrewingStandMenu.FuelSlot` and `quickMoveStack` use the same component. + #[test] + fn brewing_fuel_slot_and_quick_move_use_stack_components() { + let items = Items::for_tests(Default::default()); + let root = std::path::Path::new(""); + let recipes = Recipes::empty(); + let brewing = Brewing::load(root, &items, &PackOverlay::NONE); + let banners = Banners::default(); + let station_recipes = crate::stations::StationRecipes::default(); + let enchantments = crate::enchant::Enchantments::default(); + let names = crate::creative::Names::default(); + let env = Env { + items: &items, + recipes: &recipes, + brewing: &brewing, + banners: &banners, + station_recipes: &station_recipes, + enchantments: &enchantments, + names: &names, + trade: None, + maps: None, + limited_crafting: false, + known_recipes: None, + }; + let mut fuel = ItemStack::new("minecraft:stone", 2); + fuel.data_mut().set_loot_component( + "minecraft:brewing_fuel", + &serde_json::json!({"uses": 7, "speed_multiplier": 2.5}), + ); + let mut menu = Menu::new(Kind::Brewing); + assert!(menu.may_place(&env, 4, &fuel)); + menu.set_slot(&env, 5, Some(fuel)); + assert_eq!(menu.quick_move_landing(&env, 5), vec![4]); + let ordinary = ItemStack::new("minecraft:stone", 2); + assert!(!menu.may_place(&env, 4, &ordinary)); + menu.set_slot(&env, 5, Some(ordinary)); + assert!(!menu.quick_move_landing(&env, 5).contains(&4)); + } + /// MerchantResultSlot's removal tally and quick-craft check reset before onTake. #[test] fn merchant_takes_record_crafted_counts_once() { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingFuelOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingFuelOracle.java new file mode 100644 index 0000000000..fe8ee065e6 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingFuelOracle.java @@ -0,0 +1,67 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import java.io.PrintWriter; +import java.nio.file.*; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.BrewingFuel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BrewingStandBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.loot.LootContext; + +/** Calls native stack lookup, slot predicates, provider records and serverTick without a server. */ +public final class VanillaBrewingFuelOracle { + static LootContext context; + static final class World extends ServerLevel { + World() { super(null, null, null, null, null, null, false, 0L, List.of(), false); } + @Override public void blockEntityChanged(BlockPos pos) {} + @Override public void updateNeighbourForOutputSignal(BlockPos pos, Block block) {} + } + static final class Stand extends BrewingStandBlockEntity { + Stand() { super(BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState()); } + // Substitute only context creation, retaining native provider evaluation. + @Override protected int getUses(ServerLevel level, BrewingFuel fuel) { return fuel.uses().get(context, 0); } + @Override protected float getSpeedMultiplier(ServerLevel level, BrewingFuel fuel) { return fuel.speedMultiplier().get(context, 1); } + } + static Object field(Object object, String name) throws Exception { + var field = BrewingStandBlockEntity.class.getDeclaredField(name); + field.setAccessible(true); return field.get(object); + } + public static void main(String[] args) throws Exception { + ParityData.init(); + var constructor = LootContext.class.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + context = (LootContext) constructor.newInstance(null, net.minecraft.util.RandomSource.create(0), ParityData.lookup); + var unsafeClass = Class.forName("sun.misc.Unsafe"); + var unsafeField = unsafeClass.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); + var world = (World) unsafeClass.getMethod("allocateInstance", Class.class).invoke(unsafeField.get(null), World.class); + var slot = Class.forName("net.minecraft.world.inventory.BrewingStandMenu$FuelSlot").getDeclaredMethod("mayPlaceItem", ItemStack.class); + slot.setAccessible(true); + try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) { + for (String line : Files.readAllLines(Path.of(args[0]))) { + var input = JsonParser.parseString(line).getAsJsonObject(); + var stack = new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse(input.get("item").getAsString())), input.get("count").getAsInt()); + var patch = input.getAsJsonObject("components"); + if (patch.has("minecraft:brewing_fuel")) stack.set(DataComponents.BREWING_FUEL, BrewingFuel.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, patch.get("minecraft:brewing_fuel")).getOrThrow()); + if (patch.has("!minecraft:brewing_fuel")) stack.remove(DataComponents.BREWING_FUEL); + var stand = new Stand(); + boolean accepted = stand.canPlaceItem(4, stack); + if (accepted != (boolean) slot.invoke(null, stack) || accepted != stand.canPlaceItemThroughFace(4, stack, net.minecraft.core.Direction.NORTH)) throw new AssertionError("fuel acceptance diverged"); + stand.setItem(4, stack); + var bottles = BrewingStandBlockEntity.class.getDeclaredField("lastPotionCount"); bottles.setAccessible(true); bottles.set(stand, new boolean[3]); + for (int tick = 0; tick < 2; tick++) { + BrewingStandBlockEntity.serverTick(world, BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState(), stand); + out.println(accepted + " " + field(stand, "fuel") + " " + field(stand, "totalFuel") + " " + Integer.toUnsignedString(Float.floatToIntBits((float)field(stand, "speedMultiplier"))) + " " + stand.getItem(4).getCount()); + } + } + } + } +} diff --git a/tools/test_brewing_fuel_parity.py b/tools/test_brewing_fuel_parity.py new file mode 100644 index 0000000000..cf8ea4fd02 --- /dev/null +++ b/tools/test_brewing_fuel_parity.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Compare per-stack brewing fuel acceptance and two production refuel ticks.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + + +def corpus(): + patches = [{}, {"!minecraft:brewing_fuel": {}}, + *[{"minecraft:brewing_fuel": {"uses": uses, "speed_multiplier": speed}} + for uses, speed in [(7, 2.5), (1, 0.5), (0, 1.0), (3, 0.0)]]] + for item in ["minecraft:blaze_powder", "minecraft:stone"]: + for count in [1, 2]: + for patch in patches: + yield {"item": item, "count": count, "components": patch} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--resources", type=Path, default=lab.RESOURCES) + parser.add_argument("--profile", choices=["dev", "release"], default="release") + args = parser.parse_args() + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / "rust-brewing-fuel-parity" + build.mkdir(parents=True, exist_ok=True) + fixtures = list(corpus()) + inputs = build / "inputs.txt" + inputs.write_text("".join(json.dumps(case) + "\n" for case in fixtures), encoding="utf-8") + classes = build / "classes" + classes.mkdir(exist_ok=True) + sources = lab.ROOT / "mods/horizon/src/test/java/dev/horizonlab/parity" + tested_paths = [Path(__file__), sources / "ParityData.java", sources / "VanillaBrewingFuelOracle.java", + lab.ROOT / "crates/horizon-client-render/examples/brewing_fuel_probe.rs", + lab.ROOT / "crates/horizon-client-render/src/brewing.rs", + lab.ROOT / "crates/horizon-client-render/src/providers.rs", + lab.ROOT / "crates/horizon-items/src/items.rs", + lab.ROOT / "crates/horizon-client/src/menu.rs", + lab.ROOT / "crates/horizon-client/src/game/machines.rs", + lab.ROOT / "crates/horizon-items/src/item_data.rs", lab.ROOT / "Cargo.lock"] + tested_sources = base.source_hashes(tested_paths) + tested_fixtures = base.source_hashes([inputs]) + dependency_path = os.pathsep.join(map(str, libraries)) + + def run(command, name): + with (build / (name + ".log")).open("w", encoding="utf-8") as log: + base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT) + + run(["javac", "--release", lock["minecraft"]["java_major"], "-proc:none", "-cp", + str(jars["server"]) + os.pathsep + dependency_path, "-d", classes, + sources / "ParityData.java", sources / "VanillaBrewingFuelOracle.java"], "compile-java") + run(["cargo", "build", "--profile", args.profile, "-p", "horizon-client-render", + "--example", "brewing_fuel_probe"], "compile-rust") + target = lab.cargo_target() / ("debug" if args.profile == "dev" else "release") + binary = target / "examples" / ("brewing_fuel_probe.exe" if os.name == "nt" else "brewing_fuel_probe") + run([binary, args.resources, inputs, build / "rust.txt"], "rust") + references = [] + for side, jar in jars.items(): + reference = build / (side + ".txt") + run(["java", "-Xmx512M", "-cp", os.pathsep.join([str(classes), str(jar), dependency_path]), + "dev.horizonlab.parity.VanillaBrewingFuelOracle", inputs, reference], side) + matched = base.compare(reference, build / "rust.txt") + references.append({"side": side, "jar_sha256": lab.digest(jar, "sha256"), "matched_lines": matched}) + print(f"{side}: {matched} brewing-fuel output lines match Rust", flush=True) + base.ensure_sources_unchanged(tested_paths, tested_sources) + base.ensure_sources_unchanged([inputs], tested_fixtures) + lab.write_json(build / "report.json", {"schema": 1, "fixtures": len(fixtures), "references": references, + "sources_sha256": tested_sources, "fixtures_sha256": tested_fixtures, + "scope": "Native stack components, native FuelSlot/hopper predicates versus the Rust shared resolver, and two production serverTick refuels; native provider records evaluated with the vanilla registry resolver. Empty reagent avoids recipes/world simulation. Actual Rust menu routing, brew completion and hopper transfer are separate regression tests."}) + + +if __name__ == "__main__": + main()