mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Respect per-stack brewing fuel components
This commit is contained in:
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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user