Respect per-stack brewing fuel components

This commit is contained in:
voidsip committed 2026-11-06 15:33:46 -06:00
1 parent 41f822b659
commit 113cd40efb
6 files changed
+367 -14

No files matched your search

@@ -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<dyn std::error::Error>> {
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(())
}
+58 -1
View File
@@ -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.
+66 -4
View File
@@ -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))
@@ -1544,6 +1541,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;
+41 -9
View File
@@ -1419,10 +1419,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),
@@ -2256,11 +2253,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)
@@ -3385,6 +3378,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));
}
/// The Mouse Tweaks wheel's targets: vanilla's routing of one item.
#[test]
fn quick_move_landing_routes_a_furnace_like_vanilla() {
@@ -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());
}
}
}
}
}
+80
View File
@@ -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()