From 69c7fd925eb22cd41f0105222e75822bdf03480a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:04:56 +0200 Subject: [PATCH] Port every crafting recipe type and test recipes against vanilla The crafting module now matches shapeless recipes with a full bipartite assignment, shrinks shaped patterns like ShapedRecipePattern, keeps result components (suspicious stew effects), and implements the transmute, dye, imbue (tipped arrow), book cloning, firework rocket/star/fade, decorated pot, repair item, map extending, banner duplicate and shield decoration recipes with vanilla's matching and assembly. Crafting remainders now follow Item.craftRemainder only and are placed on the grid by recipe, so cloned books and duplicated banners stay. Stacks gain dyed colour, firework explosion, pot decorations, bundle contents, stew effects and map post-processing components, and their NBT now matches the codecs (defaults omitted, templates without count 1). A banner whose first layer was a rhombus was mistaken for the raid banner; that is fixed. tools/test_recipe_parity.py drives vanilla's RecipeManager (new VanillaRecipeOracle) and the Rust recipe_fixture over 50k generated crafting, cooking, stonecutting and smithing cases and compares matches, results and remainders as canonical NBT; all match. Co-Authored-By: Claude Sonnet 5.5 --- .../horizon-client/examples/recipe_fixture.rs | 91 ++ crates/horizon-client/src/crafting.rs | 1365 ++++++++++++++--- crates/horizon-client/src/game.rs | 16 +- .../src/game/machines/crafter.rs | 2 +- crates/horizon-client/src/item_data.rs | 346 ++++- crates/horizon-client/src/items.rs | 30 +- crates/horizon-client/src/recipe_book.rs | 2 +- .../dev/horizonlab/parity/ParityData.java | 132 ++ .../parity/VanillaRecipeOracle.java | 140 ++ tools/interaction_lab.py | 71 + tools/test_recipe_parity.py | 303 ++++ 11 files changed, 2233 insertions(+), 265 deletions(-) create mode 100644 crates/horizon-client/examples/recipe_fixture.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeOracle.java create mode 100644 tools/interaction_lab.py create mode 100644 tools/test_recipe_parity.py diff --git a/crates/horizon-client/examples/recipe_fixture.rs b/crates/horizon-client/examples/recipe_fixture.rs new file mode 100644 index 000000000..e68258e4d --- /dev/null +++ b/crates/horizon-client/examples/recipe_fixture.rs @@ -0,0 +1,91 @@ +//! Recipe lookups for tools/test_recipe_parity.py: reads the oracle's +//! tab-separated commands (craft, cook, stonecut, smith) and prints one line +//! per command in the oracle's format. +//! +//! usage: recipe_fixture RESOURCES INPUT OUTPUT +use horizon_client::{ + crafting::Recipes, + enchant::Enchantments, + item_data::{canon_stack, parse_stack}, + items::Items, + stations::StationRecipes, +}; +use std::{fs, path::Path}; + +fn main() -> horizon_client::Result<()> { + let args: Vec = std::env::args().collect(); + let root = Path::new( + args.get(1) + .ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?, + ); + let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?; + let items = Items::catalog(root)?; + let recipes = Recipes::load(root, &items)?; + let stations = StationRecipes::load(root, &items); + let enchantments = Enchantments::load(root); + let mut out = String::new(); + for line in input.lines() { + let f: Vec<&str> = line.split('\t').collect(); + let stack = |i: usize| parse_stack(f[i]); + let text = match f[0] { + "craft" => { + let n: usize = f[1].parse()?; + let mut grid = Vec::new(); + for i in 0..n * n { + grid.push(stack(2 + i)?); + } + match recipes.find(&grid, n) { + None => "none".to_owned(), + Some((id, result)) => { + let rest = recipes.remainders(id, &grid, n); + let rest: Vec = rest + .iter() + .enumerate() + .filter_map(|(i, s)| { + Some(format!("{i}={}", canon_stack(Some(s.as_ref()?)))) + }) + .collect(); + format!("{id}\t{}\t{}", canon_stack(Some(&result)), rest.join(";")) + } + } + } + "cook" => match stack(2)?.and_then(|s| { + recipes + .cook(&format!("minecraft:{}", f[1]), &s) + .map(|c| (c.id.clone(), c.result.clone(), c.experience, c.time)) + }) { + None => "none".to_owned(), + Some((id, result, xp, time)) => { + format!("{id}\t{}\t{xp:?}\t{time}", canon_stack(Some(&result))) + } + }, + "stonecut" => match stack(1)? { + None => String::new(), + Some(s) => stations + .stonecutter(&s) + .iter() + .map(|r| { + format!( + "{}={}", + r.id.trim_start_matches("minecraft:"), + canon_stack(Some(&r.result)) + ) + }) + .collect::>() + .join(";"), + }, + "smith" => { + let (t, b, a) = (stack(1)?, stack(2)?, stack(3)?); + match stations.smithing_result(&enchantments, t.as_ref(), b.as_ref(), a.as_ref()) { + None => "none".to_owned(), + Some(r) => canon_stack(Some(&r)), + } + } + other => return Err(format!("unknown command {other}").into()), + }; + out.push_str(&text); + out.push('\n'); + } + fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?; + Ok(()) +} diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index aa434a556..cf2e43f03 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -1,13 +1,28 @@ //! Crafting and cooking recipes from the pinned data pack: shaped patterns -//! (placed anywhere in the grid, optionally mirrored as in -//! `ShapedRecipePattern.matches`), shapeless ingredient sets, and furnace-type -//! cooking lookups. +//! (`ShapedRecipePattern.matches`), shapeless recipes (a bipartite match of +//! ingredients to stacks, as `StackedContents.canCraft`), the special and +//! transmuting crafting recipes (`CustomRecipe` and friends), and +//! furnace-type cooking lookups. use crate::{ Result, + item_data::StackData, items::{ItemStack, Items}, + particles::{FireworkExplosion, FireworkShape}, }; +use horizon_world::nbt::Tag; use serde_json::Value; -use std::{collections::HashSet, fs, path::Path, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + fs, + path::Path, + sync::Arc, +}; + +/// `DyeColor.getFireworkColor` in dye order (white to black). +const FIREWORK_COLORS: [u32; 16] = [ + 15_790_320, 15_435_844, 12_801_229, 6_719_955, 14_602_026, 4_312_372, 14_188_952, 4_408_131, + 11_250_603, 2_651_799, 8_073_150, 2_437_522, 5_320_730, 3_887_386, 11_743_532, 1_973_019, +]; /// A set of accepted items (expanded from an item, list or tag). #[derive(Clone, Debug)] @@ -41,22 +56,124 @@ impl Ingredient { self.0.contains(&*stack.item) } } +/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the +/// id optional for the transmuting recipes that keep the input's item. +#[derive(Clone, Debug)] +pub struct Template { + pub item: Option>, + pub count: u32, + pub damage: u32, + pub extra: Option>, +} +impl Template { + fn parse(v: &Value) -> Option { + let (damage, extra) = match v.get("components") { + Some(c) => crate::item_data::decode_components(Some(&json_tag(c))), + None => (0, None), + }; + Some(Self { + item: v["id"].as_str().map(Arc::from), + count: v["count"].as_u64().unwrap_or(1) as u32, + damage, + extra, + }) + } + /// `ItemStackTemplate.create`. + pub fn create(&self) -> ItemStack { + ItemStack { + item: self + .item + .clone() + .unwrap_or_else(|| Arc::from("minecraft:air")), + count: self.count, + damage: self.damage, + extra: self.extra.clone(), + } + } + /// `TransmuteRecipe.createWithOriginalComponents`: the input's component + /// patch on the result item, then the template's own components over it. + fn over(&self, input: &ItemStack, extra_count: u32) -> ItemStack { + let mut out = ItemStack { + item: self.item.clone().unwrap_or_else(|| input.item.clone()), + count: self.count + extra_count, + damage: input.damage, + extra: input.extra.clone(), + }; + if self.damage > 0 { + out.damage = self.damage; + } + if let Some(over) = &self.extra { + overlay(out.data_mut(), over); + } + out.tidy(); + out + } +} +/// `DataComponentPatch` application: every component the overlay sets wins. +fn overlay(dst: &mut StackData, src: &StackData) { + let none = StackData::default(); + macro_rules! field { + ($($f:ident),*) => {$( + if src.$f != none.$f { + dst.$f = src.$f.clone(); + } + )*}; + } + field!( + enchantments, + stored_enchantments, + repair_cost, + custom_name, + potion, + trim, + container, + charged, + fireworks, + map_id, + writable_pages, + written, + banner_patterns, + base_color, + ominous_banner, + ominous_amplifier, + dyed_color, + firework_explosion, + pot_decorations, + bundle_contents, + stew_effects, + map_post_processing + ); +} +/// A JSON recipe value as the NBT a component codec reads. +fn json_tag(v: &Value) -> Tag { + match v { + Value::Null => Tag::End, + Value::Bool(b) => Tag::Byte(i8::from(*b)), + Value::Number(n) => match n.as_i64() { + Some(i) => i32::try_from(i).map_or(Tag::Long(i), Tag::Int), + None => Tag::Double(n.as_f64().unwrap_or(0.0).to_bits()), + }, + Value::String(s) => Tag::String(s.as_str().into()), + Value::Array(a) => Tag::List(a.iter().map(json_tag).collect()), + Value::Object(o) => Tag::compound(o.iter().map(|(k, v)| (k.as_str(), json_tag(v)))), + } +} #[derive(Clone, Debug)] pub enum Recipe { Shaped { width: usize, height: usize, cells: Vec>, - result: ItemStack, + result: Template, }, Shapeless { ingredients: Vec, - result: ItemStack, + result: Template, }, - /// `BannerDuplicateRecipe` and `ShieldDecorationRecipe`. + /// The recipes with code of their own. Special(Special), } -/// The banner-copying custom recipes. +/// The custom and transmuting crafting recipes. #[derive(Clone, Debug)] pub enum Special { /// A patterned banner and a blank one of the same colour copy the patterns. @@ -67,70 +184,481 @@ pub enum Special { target: Ingredient, result: String, }, + /// `TransmuteRecipe`: one input, 1..8 materials; shulker box and bundle + /// dyeing, map cloning. + Transmute { + input: Ingredient, + material: Ingredient, + min: usize, + max: usize, + add_count: bool, + result: Template, + }, + /// `DyeRecipe`: dyed armour. + Dye { + target: Ingredient, + dye: Ingredient, + result: Template, + }, + /// `ImbueRecipe`: tipped arrows. + Imbue { + source: Ingredient, + material: Ingredient, + result: Template, + }, + /// `BookCloningRecipe`. + BookCloning { + source: Ingredient, + material: Ingredient, + min: i32, + max: i32, + result: Template, + }, + /// `FireworkRocketRecipe`. + FireworkRocket { + shell: Ingredient, + fuel: Ingredient, + star: Ingredient, + result: Template, + }, + /// `FireworkStarRecipe`. + FireworkStar { + dye: Ingredient, + fuel: Ingredient, + trail: Ingredient, + twinkle: Ingredient, + shapes: Vec<(FireworkShape, Ingredient)>, + result: Template, + }, + /// `FireworkStarFadeRecipe`. + FireworkStarFade { + dye: Ingredient, + target: Ingredient, + result: Template, + }, + /// `MapExtendingRecipe`: a map ringed by paper, when its scale is under 4. + MapExtending { + map: Ingredient, + material: Ingredient, + result: Template, + }, + /// `DecoratedPotRecipe`: four sherds or bricks. + DecoratedPot { + back: Ingredient, + left: Ingredient, + right: Ingredient, + front: Ingredient, + result: Template, + }, + /// `RepairItemRecipe`: two damaged items of one kind. + RepairItem, +} +/// `CraftingInput`: the occupied bounding box of the grid. +pub struct Input<'a> { + pub width: usize, + pub height: usize, + /// Position of the box in the grid. + pub left: usize, + pub top: usize, + cells: Vec>, +} +impl<'a> Input<'a> { + /// `CraftingInput.ofPositioned`; None for an empty grid. + pub fn of(grid: &'a [Option], size: usize) -> Option { + let occupied: Vec<(usize, usize)> = (0..size * size) + .filter(|&i| grid.get(i).is_some_and(|c| c.is_some())) + .map(|i| (i % size, i / size)) + .collect(); + let (x0, x1) = ( + occupied.iter().map(|p| p.0).min()?, + occupied.iter().map(|p| p.0).max()?, + ); + let (y0, y1) = ( + occupied.iter().map(|p| p.1).min()?, + occupied.iter().map(|p| p.1).max()?, + ); + let (width, height) = (x1 - x0 + 1, y1 - y0 + 1); + let cells = (0..width * height) + .map(|i| grid[(y0 + i / width) * size + x0 + i % width].as_ref()) + .collect(); + Some(Self { + width, + height, + left: x0, + top: y0, + cells, + }) + } + pub fn get(&self, x: usize, y: usize) -> Option<&'a ItemStack> { + self.cells.get(y * self.width + x).copied().flatten() + } + /// The non-empty stacks in slot order. + pub fn stacks(&self) -> Vec<&'a ItemStack> { + self.cells.iter().flatten().copied().collect() + } + pub fn count(&self) -> usize { + self.cells.iter().flatten().count() + } +} +/// What recipes need to know about an item. +#[derive(Clone, Debug, Default)] +struct Facts { + /// Index into the dye colours when the item has the `dye` component. + dye: Option, + max_damage: u32, + max_stack: u32, + remainder: Option>, +} +/// The tables recipes consult. +pub struct Context<'a> { + recipes: &'a Recipes, + /// A filled map's scale, when its data is known. + pub map_scale: &'a dyn Fn(i32) -> Option, +} +impl Context<'_> { + fn facts(&self, stack: &ItemStack) -> Option<&Facts> { + self.recipes.facts.get(&stack.item) + } + /// `itemStack.has(DataComponents.DYE)`. + fn dye(&self, stack: &ItemStack) -> Option { + self.facts(stack).and_then(|f| f.dye) + } + fn max_stack(&self, item: &str) -> u32 { + self.recipes.facts.get(item).map_or(64, |f| f.max_stack) + } } impl Special { - /// `matches` + `assemble` over the non-empty stacks of the grid. - fn assemble(&self, stacks: &[&ItemStack]) -> Option { + /// `Recipe.matches`. + fn matches(&self, ctx: &Context, input: &Input) -> bool { use crate::banner; - if stacks.len() != 2 { - return None; - } + let stacks = input.stacks(); match self { - Self::BannerDuplicate { - banner: ing, - result, - } => { - let color = banner::banner_color(&stacks[0].item)?; - let mut source = None; - let mut target = false; - for s in stacks { - if !ing.test(s) || banner::banner_color(&s.item)? != color { - return None; + Self::BannerDuplicate { banner: ing, .. } => { + if stacks.len() != 2 { + return false; + } + let mut color = None; + let (mut target, mut source) = (false, false); + for s in &stacks { + let Some(this) = banner::banner_color(&s.item).filter(|_| ing.test(s)) else { + return false; + }; + match color { + None => color = Some(this), + Some(c) if c != this => return false, + _ => {} } let layers = banner::layers(s).len(); if layers > banner::MAX_LAYERS { - return None; + return false; } - if layers > 0 { - if source.is_some() { - return None; - } - source = Some(*s); - } else { - if target { - return None; - } - target = true; + let seen = if layers > 0 { &mut source } else { &mut target }; + if *seen { + return false; } + *seen = true; } - let source = source.filter(|_| target)?; - // TransmuteRecipe.createWithOriginalComponents: the source's components. - Some(ItemStack { - item: Arc::from(result.as_str()), - count: 1, - ..(*source).clone() - }) + source && target } Self::ShieldDecoration { banner: ing, target: tgt, - result, + .. } => { - let (mut patterned, mut clear) = (None, None); - for s in stacks { + if stacks.len() != 2 { + return false; + } + let (mut patterned, mut clear) = (false, false); + for s in &stacks { if ing.test(s) && banner::banner_color(&s.item).is_some() { - if patterned.is_some() { - return None; + if patterned { + return false; } - patterned = Some(*s); + patterned = true; } else { - if !tgt.test(s) || clear.is_some() || !banner::layers(s).is_empty() { - return None; + if !tgt.test(s) || clear || !banner::layers(s).is_empty() { + return false; } - clear = Some(*s); + clear = true; } } - let (banner_stack, shield) = (patterned?, clear?); + patterned && clear + } + Self::Transmute { + input: base, + material, + min, + max, + add_count, + result, + } => { + let n = stacks.len(); + if n < min + 1 || n > max + 1 { + return false; + } + let mut found: Option<&ItemStack> = None; + let mut materials = 0; + for s in &stacks { + if base.test(s) { + if found.is_some() { + return false; + } + found = Some(s); + } else { + if !material.test(s) { + return false; + } + materials += 1; + if materials > *max { + return false; + } + } + } + let Some(found) = found else { + return false; + }; + if materials < *min || materials > *max { + return false; + } + let count = if *add_count { + materials as u32 + result.count + } else { + result.count + }; + if count != 1 { + return true; + } + let made = result.over(found, 0); + made.count <= ctx.max_stack(&made.item) && !made.same_item_same_components(found) + } + Self::Dye { target, dye, .. } => { + if stacks.len() < 2 { + return false; + } + let (mut has_target, mut has_dyes) = (false, false); + for s in &stacks { + if target.test(s) { + if has_target { + return false; + } + has_target = true; + } else { + if !dye.test(s) || ctx.dye(s).is_none() { + return false; + } + has_dyes = true; + } + } + has_dyes && has_target + } + Self::Imbue { + source, material, .. + } => { + if input.width != 3 || input.height != 3 || stacks.len() != 9 { + return false; + } + (0..3).all(|y| { + (0..3).all(|x| { + let ing = if x == 1 && y == 1 { source } else { material }; + input.get(x, y).is_some_and(|s| ing.test(s)) + }) + }) + } + Self::BookCloning { + source, + material, + min, + max, + .. + } => { + if stacks.len() < 2 { + return false; + } + let (mut has_material, mut has_source) = (false, false); + for s in &stacks { + if source.test(s) { + let Some(book) = s.data().and_then(|d| d.written.as_ref()) else { + return false; + }; + let generation = i32::from(book.generation); + if generation < *min || generation > *max || has_source { + return false; + } + has_source = true; + } else { + if !material.test(s) { + return false; + } + has_material = true; + } + } + has_source && has_material + } + Self::FireworkRocket { + shell, fuel, star, .. + } => { + if stacks.len() < 2 { + return false; + } + let mut has_shell = false; + let mut fuels = 0; + for s in &stacks { + if shell.test(s) { + if has_shell { + return false; + } + has_shell = true; + } else if fuel.test(s) { + fuels += 1; + if fuels > 3 { + return false; + } + } else if !star.test(s) { + return false; + } + } + has_shell && fuels >= 1 + } + Self::FireworkStar { + dye, + fuel, + trail, + twinkle, + shapes, + .. + } => { + if stacks.len() < 2 { + return false; + } + let (mut has_fuel, mut has_dye, mut has_shape) = (false, false, false); + let (mut has_trail, mut has_twinkle) = (false, false); + for s in &stacks { + if twinkle.test(s) { + if has_twinkle { + return false; + } + has_twinkle = true; + } else if trail.test(s) { + if has_trail { + return false; + } + has_trail = true; + } else if fuel.test(s) { + if has_fuel { + return false; + } + has_fuel = true; + } else if dye.test(s) && ctx.dye(s).is_some() { + has_dye = true; + } else { + if !shapes.iter().any(|(_, ing)| ing.test(s)) || has_shape { + return false; + } + has_shape = true; + } + } + has_fuel && has_dye + } + Self::FireworkStarFade { dye, target, .. } => { + if stacks.len() < 2 { + return false; + } + let (mut has_dye, mut has_target) = (false, false); + for s in &stacks { + if dye.test(s) && ctx.dye(s).is_some() { + has_dye = true; + } else { + if !target.test(s) || has_target { + return false; + } + has_target = true; + } + } + has_target && has_dye + } + Self::MapExtending { map, material, .. } => { + if input.width != 3 || input.height != 3 || stacks.len() != 9 { + return false; + } + let ring = (0..3).all(|y| { + (0..3).all(|x| { + let ing = if x == 1 && y == 1 { map } else { material }; + input.get(x, y).is_some_and(|s| ing.test(s)) + }) + }); + if !ring { + return false; + } + stacks + .iter() + .find_map(|s| s.data().and_then(|d| d.map_id)) + .and_then(|id| (ctx.map_scale)(id)) + .is_some_and(|scale| scale < 4) + } + Self::DecoratedPot { + back, + left, + right, + front, + .. + } => { + input.width == 3 + && input.height == 3 + && stacks.len() == 4 + && input.get(1, 0).is_some_and(|s| back.test(s)) + && input.get(0, 1).is_some_and(|s| left.test(s)) + && input.get(2, 1).is_some_and(|s| right.test(s)) + && input.get(1, 2).is_some_and(|s| front.test(s)) + } + Self::RepairItem => Self::repair_pair(ctx, &stacks).is_some(), + } + } + /// `RepairItemRecipe.getItemsToCombine`. + fn repair_pair<'a>( + ctx: &Context, + stacks: &[&'a ItemStack], + ) -> Option<(&'a ItemStack, &'a ItemStack)> { + let [first, second] = stacks else { + return None; + }; + let durable = |s: &ItemStack| ctx.facts(s).is_some_and(|f| f.max_damage > 0); + (second.item == first.item + && first.count == 1 + && second.count == 1 + && durable(first) + && durable(second)) + .then_some((*first, *second)) + } + /// `Recipe.assemble`; None is the empty stack. + fn assemble(&self, ctx: &Context, input: &Input) -> Option { + use crate::banner; + let stacks = input.stacks(); + let made = match self { + Self::BannerDuplicate { result, .. } => { + let source = stacks + .iter() + .find(|s| (1..=banner::MAX_LAYERS).contains(&banner::layers(s).len()))?; + // TransmuteRecipe.createWithOriginalComponents: the source's components. + ItemStack { + item: Arc::from(result.as_str()), + count: 1, + ..(**source).clone() + } + } + Self::ShieldDecoration { + banner: ing, + target, + result, + } => { + let (mut banner_stack, mut shield) = (None, None); + for s in &stacks { + if ing.test(s) && banner::banner_color(&s.item).is_some() { + banner_stack = Some(*s); + } else if target.test(s) { + shield = Some(*s); + } + } + let (banner_stack, shield) = (banner_stack?, shield?); let mut out = ItemStack { item: Arc::from(result.as_str()), count: 1, @@ -139,11 +667,258 @@ impl Special { let data = out.data_mut(); data.banner_patterns = banner::layers(banner_stack).to_vec(); data.base_color = banner::banner_color(&banner_stack.item).map(Arc::from); - Some(out) + out } - } + Self::Transmute { + input: base, + material, + add_count, + result, + .. + } => { + if *add_count { + let mut materials = 0; + let mut found = None; + for s in &stacks { + if base.test(s) { + found = Some(*s); + } else if material.test(s) { + materials += 1; + } + } + result.over(found?, materials) + } else { + let s = stacks.iter().find(|s| base.test(s))?; + result.over(s, 0) + } + } + Self::Dye { + target, + dye, + result, + } => { + let mut dyes = Vec::new(); + let mut target_stack: Option<&ItemStack> = None; + for s in &stacks { + if target.test(s) { + if target_stack.is_some() { + return None; + } + target_stack = Some(s); + } else { + if !dye.test(s) { + return None; + } + // getOrDefault(DYE, WHITE) + dyes.push(ctx.dye(s).unwrap_or(0)); + } + } + let target_stack = target_stack?; + if dyes.is_empty() { + return None; + } + let current = target_stack.data().and_then(|d| d.dyed_color); + let mut out = result.over(target_stack, 0); + out.data_mut().dyed_color = Some(apply_dyes(current, &dyes)); + out + } + Self::Imbue { result, .. } => { + let source = input.get(1, 1)?; + let mut out = result.create(); + out.data_mut().potion = source.data().and_then(|d| d.potion.clone()); + out + } + Self::BookCloning { + source, + material, + result, + .. + } => { + let mut copies = 0u32; + let mut book: Option<&ItemStack> = None; + for s in &stacks { + if source.test(s) && s.data().is_some_and(|d| d.written.is_some()) { + if book.is_some() { + return None; + } + book = Some(s); + } else { + if !material.test(s) { + return None; + } + copies += 1; + } + } + let book = book?; + // count - 1 can be -1 when no material was seen; matches() ensures one. + let mut out = result.over(book, copies.saturating_sub(1)); + if let Some(w) = out.data_mut().written.as_mut() { + w.generation += 1; + } + out + } + Self::FireworkRocket { + fuel, star, result, .. + } => { + let mut explosions = Vec::new(); + let mut fuels = 0u8; + for s in &stacks { + if fuel.test(s) { + fuels += 1; + } else if star.test(s) + && let Some(e) = s.data().and_then(|d| d.firework_explosion.clone()) + { + explosions.push(e); + } + } + let mut out = result.create(); + // A rocket's prototype is one flight and no explosions. + if fuels != 1 || !explosions.is_empty() { + out.data_mut().fireworks = Some((fuels, explosions)); + } + out + } + Self::FireworkStar { + dye, + trail, + twinkle, + shapes, + result, + .. + } => { + let mut shape = FireworkShape::SmallBall; + let (mut has_twinkle, mut has_trail) = (false, false); + let mut colors = Vec::new(); + for s in &stacks { + if let Some((found, _)) = shapes.iter().find(|(_, ing)| ing.test(s)) { + shape = *found; + } else if twinkle.test(s) { + has_twinkle = true; + } else if trail.test(s) { + has_trail = true; + } else if dye.test(s) { + colors.push(FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)]); + } + } + let mut out = result.create(); + out.data_mut().firework_explosion = Some(FireworkExplosion { + shape, + colors, + fade: Vec::new(), + trail: has_trail, + twinkle: has_twinkle, + }); + out + } + Self::FireworkStarFade { + dye, + target, + result, + } => { + let mut colors = Vec::new(); + let mut target_stack = None; + for s in &stacks { + if dye.test(s) { + colors.push(FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)]); + } else if target.test(s) { + target_stack = Some(*s); + } + } + let target_stack = target_stack?; + if colors.is_empty() { + return None; + } + let mut out = result.over(target_stack, 0); + let explosion = out + .data_mut() + .firework_explosion + .get_or_insert_with(FireworkExplosion::default); + explosion.fade = colors; + out + } + Self::MapExtending { result, .. } => { + let map = stacks + .iter() + .find(|s| s.data().is_some_and(|d| d.map_id.is_some()))?; + let mut out = result.over(map, 0); + out.data_mut().map_post_processing = Some(1); + out + } + Self::DecoratedPot { result, .. } => { + let side = |x, y| { + input.get(x, y).map(|s| ItemStack { + count: 1, + ..s.clone() + }) + }; + let mut out = result.create(); + let sides = [side(1, 0), side(0, 1), side(2, 1), side(1, 2)]; + if !crate::item_data::all_bricks(&sides) { + out.data_mut().pot_decorations = Some(Box::new(sides)); + } + out + } + Self::RepairItem => { + let (first, second) = Self::repair_pair(ctx, &stacks)?; + let max = |s: &ItemStack| ctx.facts(s).map_or(0, |f| f.max_damage) as i64; + let durability = max(first).max(max(second)); + let remaining = (max(first) - i64::from(first.damage)) + + (max(second) - i64::from(second.damage)) + + durability * 5 / 100; + let mut out = ItemStack::new(&first.item, 1); + out.damage = (durability - remaining).max(0) as u32; + // Only the curses of both survive, at the higher level. + let (a, b) = ( + first.crafting_enchantments(), + second.crafting_enchantments(), + ); + let mut curses: Vec<(Arc, u32)> = Vec::new(); + for (id, level) in a.iter().chain(b.iter()) { + if !ctx.recipes.curses.contains(&**id) { + continue; + } + match curses.iter_mut().find(|(c, _)| c == id) { + Some((_, l)) => *l = (*l).max(*level), + None => curses.push((id.clone(), *level)), + } + } + out.set_crafting_enchantments(curses); + out + } + }; + let mut made = made; + made.tidy(); + // ItemStackTemplate.validate: oversized stacks become empty. + (made.count > 0 && made.count <= ctx.max_stack(&made.item)).then_some(made) } } +/// `DyedItemColor.applyDyes` over dye indexes. +fn apply_dyes(current: Option, dyes: &[usize]) -> i32 { + let (mut r, mut g, mut b, mut intensity, mut n) = (0i32, 0i32, 0i32, 0i32, 0i32); + let mut add = |rgb: u32| { + let (red, green, blue) = ( + (rgb >> 16 & 255) as i32, + (rgb >> 8 & 255) as i32, + (rgb & 255) as i32, + ); + intensity += red.max(green.max(blue)); + r += red; + g += green; + b += blue; + n += 1; + }; + if let Some(c) = current { + add(c as u32); + } + for d in dyes { + add(crate::block_entity_render::DYES[*d].1); + } + let (red, green, blue) = (r / n, g / n, b / n); + let average = intensity as f32 / n as f32; + let result = red.max(green.max(blue)) as f32; + let scale = |c: i32| (c as f32 * average / result) as i32; + scale(red) << 16 | scale(green) << 8 | scale(blue) +} #[derive(Clone, Debug)] pub struct Cooking { /// The recipe's file name (its id without namespace). @@ -161,21 +936,84 @@ pub struct Recipes { pub crafting: Vec<(String, Recipe)>, pub cooking: Vec, /// `category` and `group` of each crafting recipe, by file name. - pub meta: std::collections::HashMap, + pub meta: HashMap, + facts: HashMap, Facts>, + /// The `#minecraft:curse` enchantments. + curses: HashSet, } -fn result(v: &Value) -> Option { - Some(ItemStack { - item: Arc::from(v["id"].as_str()?), - count: v["count"].as_u64().unwrap_or(1) as u32, - damage: 0, - extra: None, +/// `MinMaxBounds.Ints` as (min, max) with the given defaults. +fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) { + match v { + Some(Value::Number(n)) => { + let n = n.as_i64().unwrap_or(default.0); + (n, n) + } + Some(o @ Value::Object(_)) => ( + o["min"].as_i64().unwrap_or(open.0), + o["max"].as_i64().unwrap_or(open.1), + ), + _ => default, + } +} +/// `Util.isSymmetrical` is not needed: both orientations are tried. +/// `ShapedRecipePattern.shrink`: the pattern without empty border rows and columns. +fn shrink(pattern: &[Vec]) -> Vec> { + let rows: Vec<&Vec> = pattern + .iter() + .skip_while(|r| r.iter().all(|c| *c == ' ')) + .collect(); + let mut rows: Vec> = rows.into_iter().cloned().collect(); + while rows.last().is_some_and(|r| r.iter().all(|c| *c == ' ')) { + rows.pop(); + } + let left = rows + .iter() + .filter_map(|r| r.iter().position(|c| *c != ' ')) + .min(); + let right = rows + .iter() + .filter_map(|r| r.iter().rposition(|c| *c != ' ')) + .max(); + match (left, right) { + (Some(l), Some(r)) => rows.iter().map(|row| row[l..=r].to_vec()).collect(), + _ => Vec::new(), + } +} +/// A perfect matching of the ingredients to distinct stacks (`StackedContents.canCraft`). +fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool { + fn augment( + i: usize, + ingredients: &[Ingredient], + stacks: &[&ItemStack], + owner: &mut [Option], + seen: &mut [bool], + ) -> bool { + for s in 0..stacks.len() { + if seen[s] || !ingredients[i].test(stacks[s]) { + continue; + } + seen[s] = true; + if owner[s].is_none_or(|o| augment(o, ingredients, stacks, owner, seen)) { + owner[s] = Some(i); + return true; + } + } + false + } + if ingredients.len() != stacks.len() { + return false; + } + let mut owner = vec![None; stacks.len()]; + (0..ingredients.len()).all(|i| { + let mut seen = vec![false; stacks.len()]; + augment(i, ingredients, stacks, &mut owner, &mut seen) }) } impl Recipes { pub fn load(root: &Path, items: &Items) -> Result { let mut crafting = Vec::new(); let mut cooking = Vec::new(); - let mut meta = std::collections::HashMap::new(); + let mut meta = HashMap::new(); let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe"))? .flatten() .map(|e| e.path()) @@ -191,7 +1029,10 @@ impl Recipes { let v: Value = serde_json::from_slice(&fs::read(&path)?)?; let text = |k: &str| v[k].as_str().unwrap_or("").to_owned(); meta.insert(name.clone(), (text("category"), text("group"))); - match v["type"].as_str().unwrap_or("") { + let ing = |k: &str| Ingredient::parse(&v[k], items); + let result = || Template::parse(&v["result"]); + let special = |recipe: Option| recipe.map(Recipe::Special); + let parsed: Option = match v["type"].as_str().unwrap_or("") { "minecraft:crafting_shaped" => { let pattern: Vec> = v["pattern"] .as_array() @@ -199,14 +1040,14 @@ impl Recipes { .flatten() .filter_map(|r| r.as_str().map(|s| s.chars().collect())) .collect(); + let pattern = shrink(&pattern); let height = pattern.len(); - let width = pattern.iter().map(Vec::len).max().unwrap_or(0); + let width = pattern.first().map_or(0, Vec::len); let mut cells = Vec::with_capacity(width * height); - let mut ok = true; + let mut ok = height > 0; for row in &pattern { - for x in 0..width { - let c = row.get(x).copied().unwrap_or(' '); - if c == ' ' { + for c in row { + if *c == ' ' { cells.push(None); } else { match Ingredient::parse(&v["key"][c.to_string()], items) { @@ -216,16 +1057,14 @@ impl Recipes { } } } - if let (true, Some(result)) = (ok, result(&v["result"])) { - crafting.push(( - name, - Recipe::Shaped { - width, - height, - cells, - result, - }, - )); + match (ok, result()) { + (true, Some(result)) => Some(Recipe::Shaped { + width, + height, + cells, + result, + }), + _ => None, } } "minecraft:crafting_shapeless" => { @@ -235,73 +1074,168 @@ impl Recipes { .flatten() .map(|i| Ingredient::parse(i, items)) .collect(); - if let (Some(ingredients), Some(result)) = (ingredients, result(&v["result"])) { - crafting.push(( - name, - Recipe::Shapeless { - ingredients, - result, - }, - )); + match (ingredients, result()) { + (Some(ingredients), Some(result)) => Some(Recipe::Shapeless { + ingredients, + result, + }), + _ => None, } } - "minecraft:crafting_special_bannerduplicate" => { - if let (Some(banner), Some(id)) = ( - Ingredient::parse(&v["banner"], items), - v["result"]["id"].as_str(), - ) { - crafting.push(( - name, - Recipe::Special(Special::BannerDuplicate { - banner, - result: id.to_owned(), - }), + "minecraft:crafting_special_bannerduplicate" => special( + ing("banner") + .zip(v["result"]["id"].as_str()) + .map(|(banner, id)| Special::BannerDuplicate { + banner, + result: id.to_owned(), + }), + ), + "minecraft:crafting_special_shielddecoration" => special( + ing("banner") + .zip(ing("target")) + .zip(v["result"]["id"].as_str()) + .map(|((banner, target), id)| Special::ShieldDecoration { + banner, + target, + result: id.to_owned(), + }), + ), + "minecraft:crafting_transmute" => special((|| { + let (min, max) = bounds(v.get("material_count"), (1, 1), (1, 8)); + Some(Special::Transmute { + input: ing("input")?, + material: ing("material")?, + min: min.clamp(1, 8) as usize, + max: max.clamp(1, 8) as usize, + add_count: v["add_material_count_to_result"].as_bool().unwrap_or(false), + result: result()?, + }) + })()), + "minecraft:crafting_dye" => special((|| { + Some(Special::Dye { + target: ing("target")?, + dye: ing("dye")?, + result: result()?, + }) + })()), + "minecraft:crafting_imbue" => special((|| { + Some(Special::Imbue { + source: ing("source")?, + material: ing("material")?, + result: result()?, + }) + })()), + "minecraft:crafting_special_bookcloning" => special((|| { + let (min, max) = bounds(v.get("allowed_generations"), (0, 1), (0, 2)); + Some(Special::BookCloning { + source: ing("source")?, + material: ing("material")?, + min: min as i32, + max: max as i32, + result: result()?, + }) + })()), + "minecraft:crafting_special_firework_rocket" => special((|| { + Some(Special::FireworkRocket { + shell: ing("shell")?, + fuel: ing("fuel")?, + star: ing("star")?, + result: result()?, + }) + })()), + "minecraft:crafting_special_firework_star" => special((|| { + let mut shapes = Vec::new(); + for (shape, value) in v["shapes"].as_object()? { + shapes.push(( + FireworkShape::from_name(shape), + Ingredient::parse(value, items)?, )); } - } - "minecraft:crafting_special_shielddecoration" => { - if let (Some(banner), Some(target), Some(id)) = ( - Ingredient::parse(&v["banner"], items), - Ingredient::parse(&v["target"], items), - v["result"]["id"].as_str(), - ) { - crafting.push(( - name, - Recipe::Special(Special::ShieldDecoration { - banner, - target, - result: id.to_owned(), - }), - )); - } - } + Some(Special::FireworkStar { + dye: ing("dye")?, + fuel: ing("fuel")?, + trail: ing("trail")?, + twinkle: ing("twinkle")?, + shapes, + result: result()?, + }) + })()), + "minecraft:crafting_special_firework_star_fade" => special((|| { + Some(Special::FireworkStarFade { + dye: ing("dye")?, + target: ing("target")?, + result: result()?, + }) + })()), + "minecraft:crafting_special_mapextending" => special((|| { + Some(Special::MapExtending { + map: ing("map")?, + material: ing("material")?, + result: result()?, + }) + })()), + "minecraft:crafting_decorated_pot" => special((|| { + Some(Special::DecoratedPot { + back: ing("back")?, + left: ing("left")?, + right: ing("right")?, + front: ing("front")?, + result: result()?, + }) + })()), + "minecraft:crafting_special_repairitem" => special(Some(Special::RepairItem)), kind @ ("minecraft:smelting" | "minecraft:blasting" | "minecraft:smoking" | "minecraft:campfire_cooking") => { - if let (Some(ingredient), Some(result)) = ( - Ingredient::parse(&v["ingredient"], items), - result(&v["result"]), - ) { + if let (Some(ingredient), Some(result)) = (ing("ingredient"), result()) { cooking.push(Cooking { - id: name, + id: name.clone(), category: text("category"), group: text("group"), kind: kind.to_owned(), ingredient, - result, + result: result.create(), experience: v["experience"].as_f64().unwrap_or(0.0) as f32, time: v["cookingtime"].as_u64().unwrap_or(200) as u32, }); } + None } - _ => {} + _ => None, + }; + if let Some(recipe) = parsed { + crafting.push((name, recipe)); } } + let facts = items + .items + .iter() + .map(|(id, info)| { + ( + id.clone(), + Facts { + dye: info.dye.as_deref().and_then(|d| { + crate::block_entity_render::DYES + .iter() + .position(|(name, _)| *name == d) + }), + max_damage: info.max_damage, + max_stack: info.max_stack, + remainder: info.crafting_remainder.as_deref().map(Arc::from), + }, + ) + }) + .collect(); + let curses = crate::items::load_tags(root, "enchantment")? + .remove("minecraft:curse") + .unwrap_or_default(); Ok(Self { crafting, cooking, meta, + facts, + curses, }) } /// Match a `size`x`size` grid (row-major) against the crafting recipes. @@ -310,31 +1244,28 @@ impl Recipes { } /// `craft` with the matched recipe's id (the file name without namespace). pub fn find(&self, grid: &[Option], size: usize) -> Option<(&str, ItemStack)> { - // Trim to the occupied bounding box. - let occupied: Vec<(usize, usize)> = (0..size * size) - .filter(|&i| grid[i].is_some()) - .map(|i| (i % size, i / size)) - .collect(); - if occupied.is_empty() { - return None; - } - let (x0, x1) = ( - occupied.iter().map(|p| p.0).min()?, - occupied.iter().map(|p| p.0).max()?, - ); - let (y0, y1) = ( - occupied.iter().map(|p| p.1).min()?, - occupied.iter().map(|p| p.1).max()?, - ); - let (w, h) = (x1 - x0 + 1, y1 - y0 + 1); - let cell = |x: usize, y: usize| grid[(y0 + y) * size + x0 + x].as_ref(); + self.find_with(grid, size, &|_| None) + } + /// `RecipeManager.getRecipeFor(CRAFTING, ...)` with a lookup for the scale + /// of a filled map (map extending reads the map's saved data). + pub fn find_with( + &self, + grid: &[Option], + size: usize, + map_scale: &dyn Fn(i32) -> Option, + ) -> Option<(&str, ItemStack)> { + let input = Input::of(grid, size)?; + let ctx = Context { + recipes: self, + map_scale, + }; for (name, recipe) in &self.crafting { - match recipe { + let result = match recipe { Recipe::Special(special) => { - let stacks: Vec<&ItemStack> = grid.iter().flatten().collect(); - if let Some(result) = special.assemble(&stacks) { - return Some((name, result)); + if !special.matches(&ctx, &input) { + continue; } + special.assemble(&ctx, &input) } Recipe::Shaped { width, @@ -342,14 +1273,17 @@ impl Recipes { cells, result, } => { - if *width != w || *height != h { + if *width != input.width + || *height != input.height + || cells.iter().flatten().count() != input.count() + { continue; } let matches = |mirror: bool| { - (0..h).all(|y| { - (0..w).all(|x| { - let rx = if mirror { w - 1 - x } else { x }; - match (&cells[y * w + rx], cell(x, y)) { + (0..*height).all(|y| { + (0..*width).all(|x| { + let rx = if mirror { width - 1 - x } else { x }; + match (&cells[y * width + rx], input.get(x, y)) { (None, None) => true, (Some(i), Some(s)) => i.test(s), _ => false, @@ -357,60 +1291,76 @@ impl Recipes { }) }) }; - if matches(false) || matches(true) { - return Some((name, result.clone())); + if !(matches(false) || matches(true)) { + continue; } + Some(result.create()) } Recipe::Shapeless { ingredients, result, } => { - let stacks: Vec<&ItemStack> = grid.iter().flatten().collect(); - if stacks.len() != ingredients.len() { + if !matches_all(ingredients, &input.stacks()) { continue; } - // Greedy bipartite matching is sufficient for vanilla's ingredient sets. - let mut used = vec![false; stacks.len()]; - let ok = ingredients.iter().all(|ingredient| { - if let Some(i) = - (0..stacks.len()).find(|&i| !used[i] && ingredient.test(stacks[i])) - { - used[i] = true; - true - } else { - false - } - }); - if ok { - return Some((name, result.clone())); - } + Some(result.create()) } - } + }; + return result.map(|r| (name.as_str(), r)); } None } - /// `Recipe.getRemainingItems` per grid cell: an item's crafting remainder - /// (buckets, bottles), and the patterned banner a duplicate keeps. + /// `Recipe.getRemainingItems` placed on the `size`x`size` grid: an item's + /// crafting remainder (buckets, bottles), the patterned banner a duplicate + /// keeps and the written book a clone keeps. pub fn remainders( &self, - items: &Items, + recipe: &str, grid: &[Option], - result: &ItemStack, + size: usize, ) -> Vec> { - grid.iter() - .map(|cell| { - let s = cell.as_ref()?; - if let Some(rem) = items.get(&s.item).and_then(|i| i.remainder.as_deref()) { - return Some(ItemStack::new(rem, 1)); + let mut out = vec![None; size * size]; + let Some(input) = Input::of(grid, size) else { + return out; + }; + let kind = self + .crafting + .iter() + .find(|(n, _)| n == recipe) + .map(|(_, r)| r); + for (i, cell) in input.cells.iter().enumerate() { + let Some(s) = cell else { continue }; + let at = (input.top + i / input.width) * size + input.left + i % input.width; + let rest = self + .facts + .get(&s.item) + .and_then(|f| f.remainder.as_deref()) + .map(|r| ItemStack::new(r, 1)); + if rest.is_some() { + out[at] = rest; + continue; + } + let copy = ItemStack { + count: 1, + ..(*s).clone() + }; + match kind { + Some(Recipe::Special(Special::BannerDuplicate { .. })) + if !crate::banner::layers(s).is_empty() => + { + out[at] = Some(copy); } - (result.item.ends_with("_banner") && !crate::banner::layers(s).is_empty()).then( - || ItemStack { - count: 1, - ..s.clone() - }, - ) - }) - .collect() + // BookCloningRecipe stops at the first copied book. + Some(Recipe::Special(Special::BookCloning { .. })) + if s.data().is_some_and(|d| d.written.is_some()) => + { + out[at] = Some(copy); + break; + } + _ => {} + } + } + out } pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> { self.cooking @@ -432,6 +1382,26 @@ mod tests { s.data_mut().banner_patterns = vec![BannerLayer::new("minecraft:cross", "red")]; s } + fn run(special: &Special, cells: &[ItemStack]) -> Option { + let recipes = Recipes { + crafting: Vec::new(), + cooking: Vec::new(), + meta: HashMap::new(), + facts: HashMap::new(), + curses: HashSet::new(), + }; + let ctx = Context { + recipes: &recipes, + map_scale: &|_| None, + }; + let mut grid: Vec> = cells.iter().cloned().map(Some).collect(); + grid.resize(9, None); + let input = Input::of(&grid, 3)?; + special + .matches(&ctx, &input) + .then(|| special.assemble(&ctx, &input)) + .flatten() + } #[test] fn banner_duplicate_copies_the_patterns_of_the_same_colour() { let recipe = Special::BannerDuplicate { @@ -440,14 +1410,14 @@ mod tests { }; let source = patterned("minecraft:white_banner"); let blank = ItemStack::new("minecraft:white_banner", 1); - let out = recipe.assemble(&[&source, &blank]).expect("copy"); + let out = run(&recipe, &[source.clone(), blank.clone()]).expect("copy"); assert_eq!(out.data().unwrap().banner_patterns.len(), 1); // Two blanks, or two patterned banners, do not match. - assert!(recipe.assemble(&[&blank, &blank]).is_none()); - assert!(recipe.assemble(&[&source, &source]).is_none()); + assert!(run(&recipe, &[blank.clone(), blank.clone()]).is_none()); + assert!(run(&recipe, &[source.clone(), source.clone()]).is_none()); // Another colour is not in the recipe's ingredient. let other = ItemStack::new("minecraft:red_banner", 1); - assert!(recipe.assemble(&[&source, &other]).is_none()); + assert!(run(&recipe, &[source, other]).is_none()); } #[test] fn shield_decoration_needs_a_bare_shield() { @@ -458,11 +1428,26 @@ mod tests { }; let banner = patterned("minecraft:red_banner"); let shield = ItemStack::new("minecraft:shield", 1); - let out = recipe.assemble(&[&banner, &shield]).expect("decorated"); + let out = run(&recipe, &[banner.clone(), shield]).expect("decorated"); let data = out.data().unwrap(); assert_eq!(data.base_color.as_deref(), Some("red")); assert_eq!(data.banner_patterns.len(), 1); // A shield that already has patterns cannot be decorated again. - assert!(recipe.assemble(&[&banner, &out]).is_none()); + assert!(run(&recipe, &[banner, out]).is_none()); + } + #[test] + fn shapeless_matching_assigns_each_stack_one_ingredient() { + let any = ingredient(&["a", "b"]); + let only_a = ingredient(&["a"]); + let (a, b) = (ItemStack::new("a", 1), ItemStack::new("b", 1)); + // Greedy matching would give `a` to the first ingredient and fail. + assert!(matches_all(&[any.clone(), only_a.clone()], &[&a, &b])); + assert!(!matches_all(&[only_a.clone(), only_a], &[&a, &b])); + assert!(!matches_all(&[any], &[&a, &b])); + } + #[test] + fn dyes_blend_like_dyed_item_color() { + // One red dye on an undyed item gives the dye's own colour. + assert_eq!(apply_dyes(None, &[14]), 0xB02E26); } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b66937e3b..e8d7eb2e4 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3185,9 +3185,17 @@ impl Game<'_> { } } fn craft_result(&self) -> Option { + self.craft_recipe().map(|(_, result)| result) + } + /// The recipe id and result of the crafting grid. + fn craft_recipe(&self) -> Option<(String, ItemStack)> { let n = self.craft_size; let grid: Vec> = (0..n * n).map(|i| self.craft_grid[i].clone()).collect(); - self.res.recipes.craft(&grid, n) + let scale = |id: i32| self.maps.data.get(&id).map(|m| m.scale); + self.res + .recipes + .find_with(&grid, n, &scale) + .map(|(name, result)| (name.to_owned(), result)) } fn draw_inventory(&mut self, batch: &mut HudBatch) { if self.draw_station(batch) { @@ -3484,13 +3492,13 @@ impl Game<'_> { if slot == 200 { // ResultSlot: take the crafted stack and consume one of each ingredient. let mut crafted = 0; - while let Some(result) = self.craft_result() { + while let Some((recipe, result)) = self.craft_recipe() { let made = (result.item.clone(), result.count); let cells = self.craft_size * self.craft_size; let remainders = self.res.recipes.remainders( - &self.res.items, + &recipe, &self.craft_grid[..cells], - &result, + self.craft_size, ); if shift { if self.give(result).is_some() { diff --git a/crates/horizon-client/src/game/machines/crafter.rs b/crates/horizon-client/src/game/machines/crafter.rs index 0d1432526..470424c44 100644 --- a/crates/horizon-client/src/game/machines/crafter.rs +++ b/crates/horizon-client/src/game/machines/crafter.rs @@ -125,7 +125,7 @@ impl Game<'_> { true, horizon_world::level::flags::UPDATE_CLIENTS, ); - let remainders = self.res.recipes.remainders(&self.res.items, &grid, &result); + let remainders = self.res.recipes.remainders(&recipe, &grid, 3); self.crafter_eject(key, facing, result, &recipe); for rest in remainders.into_iter().flatten() { self.crafter_eject(key, facing, rest, &recipe); diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index 21273d804..e4329a140 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -44,6 +44,18 @@ pub struct StackData { pub ominous_banner: bool, /// `minecraft:ominous_bottle_amplifier` (None: the item's default, 0). pub ominous_amplifier: Option, + /// `minecraft:dyed_color` (leather armour, wolf armour, ... as RGB). + pub dyed_color: Option, + /// `minecraft:firework_explosion` (a firework star). + pub firework_explosion: Option, + /// `minecraft:pot_decorations`: back, left, right, front sherds. + pub pot_decorations: Option; 4]>>, + /// `minecraft:bundle_contents`. + pub bundle_contents: Vec, + /// `minecraft:suspicious_stew_effects`: (effect id, duration). + pub stew_effects: Vec<(Arc, i32)>, + /// `minecraft:map_post_processing`: 0 lock, 1 scale. + pub map_post_processing: Option, } /// `Raid.getBannerComponentPatch`: the ominous banner's pattern layers. pub const OMINOUS_LAYERS: [(&str, &str); 8] = [ @@ -243,6 +255,151 @@ fn read_text(t: &Tag) -> Option { } } +/// A `FireworkExplosion` as vanilla's codec writes it (defaults omitted). +fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag { + let colors = |c: &[u32]| Tag::List(c.iter().map(|c| Tag::Int(*c as i32)).collect()); + let mut fields = vec![("shape", Tag::String(e.shape.name().into()))]; + if !e.colors.is_empty() { + fields.push(("colors", colors(&e.colors))); + } + if !e.fade.is_empty() { + fields.push(("fade_colors", colors(&e.fade))); + } + if e.trail { + fields.push(("has_trail", Tag::Byte(1))); + } + if e.twinkle { + fields.push(("has_twinkle", Tag::Byte(1))); + } + Tag::compound(fields) +} +fn explosion_from(e: &Tag) -> crate::particles::FireworkExplosion { + let ints = |t: Option<&Tag>| match t { + Some(Tag::IntArray(v)) => v.iter().map(|c| *c as u32).collect(), + Some(Tag::List(v)) => v.iter().filter_map(int).map(|c| c as u32).collect(), + _ => Vec::new(), + }; + crate::particles::FireworkExplosion { + shape: crate::particles::FireworkShape::from_name( + e.get("shape") + .and_then(string) + .as_deref() + .map_or("small_ball", |s| s.strip_prefix("minecraft:").unwrap_or(s)), + ), + colors: ints(e.get("colors")), + fade: ints(e.get("fade_colors")), + trail: e.get("has_trail").and_then(int).unwrap_or(0) != 0, + twinkle: e.get("has_twinkle").and_then(int).unwrap_or(0) != 0, + } +} +/// An `ItemStackTemplate`: `{id, count?, components?}`. +fn template_tag(stack: &ItemStack) -> Tag { + let mut entries = vec![("id", Tag::String((&*stack.item).into()))]; + if stack.count != 1 { + entries.push(("count", Tag::Int(stack.count as i32))); + } + if let Some(components) = encode_components(stack) { + entries.push(("components", components)); + } + Tag::compound(entries) +} +/// The pot prototype's decorations: a brick on every side. +pub fn all_bricks(sides: &[Option; 4]) -> bool { + sides.iter().all(|s| { + s.as_ref() + .is_some_and(|s| &*s.item == "minecraft:brick" && s.count == 1 && s.extra.is_none()) + }) +} +fn template_from(tag: &Tag) -> Option { + match tag { + Tag::String(id) => Some(ItemStack::new(&id.to_string_lossy(), 1)), + _ => { + let id = tag.get("id").and_then(string)?; + let (damage, extra) = decode_components(tag.get("components")); + Some(ItemStack { + item: Arc::from(id.as_str()), + count: tag.get("count").and_then(int).unwrap_or(1).max(1) as u32, + damage, + extra, + }) + } + } +} +/// Sorted-key, type-annotated text of a tag (the differential harnesses +/// print the same form from the vanilla side). +pub fn canon_tag(tag: &Tag) -> String { + fn list(out: &mut String, prefix: &str, items: impl Iterator) { + out.push_str(prefix); + out.push_str(&items.collect::>().join(",")); + out.push(']'); + } + fn go(tag: &Tag, out: &mut String) { + use std::fmt::Write as _; + match tag { + Tag::End => {} + Tag::Byte(v) => write!(out, "{v}b").unwrap(), + Tag::Short(v) => write!(out, "{v}s").unwrap(), + Tag::Int(v) => write!(out, "{v}").unwrap(), + Tag::Long(v) => write!(out, "{v}L").unwrap(), + Tag::Float(v) => write!(out, "{}f", f32::from_bits(*v)).unwrap(), + Tag::Double(v) => write!(out, "{}d", f64::from_bits(*v)).unwrap(), + Tag::String(s) => write!( + out, + "\"{}\"", + s.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + ) + .unwrap(), + Tag::ByteArray(v) => list(out, "[B;", v.iter().map(|b| (*b as i8).to_string())), + Tag::IntArray(v) => list(out, "[I;", v.iter().map(i32::to_string)), + Tag::LongArray(v) => list(out, "[L;", v.iter().map(i64::to_string)), + Tag::List(items) => { + out.push('['); + for (i, t) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + go(t, out); + } + out.push(']'); + } + Tag::Compound(map) => { + let mut keys: Vec<(String, &Tag)> = + map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + out.push('{'); + for (i, (k, v)) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&k); + out.push(':'); + go(v, out); + } + out.push('}'); + } + } + } + let mut out = String::new(); + go(tag, &mut out); + out +} +/// `canon_tag` of a stack as `ItemStack.CODEC` writes it; "-" for none. +pub fn canon_stack(stack: Option<&ItemStack>) -> String { + stack.map_or_else( + || "-".to_owned(), + |s| canon_tag(&crate::save::item_to_nbt(s)), + ) +} +/// The stack of an SNBT `ItemStack.CODEC` compound; None for "-". +pub fn parse_stack(snbt: &str) -> Result, String> { + if snbt == "-" { + return Ok(None); + } + let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?; + Ok(crate::save::item_from_nbt(&tag)) +} /// The `components` compound for a stack, or None when every component is default. pub fn encode_components(stack: &ItemStack) -> Option { components_tag(stack.damage, stack.data()) @@ -317,40 +474,73 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { .collect(); entries.push(( "minecraft:written_book_content", - Tag::compound([ - ("title", raw(Tag::String(w.title.as_str().into()))), - ("author", Tag::String(w.author.as_str().into())), - ("generation", Tag::Int(i32::from(w.generation))), - ("pages", Tag::List(pages)), - ("resolved", Tag::Byte(1)), - ]), + Tag::compound({ + let mut fields = vec![ + ("title", raw(Tag::String(w.title.as_str().into()))), + ("author", Tag::String(w.author.as_str().into())), + ("pages", Tag::List(pages)), + ]; + if w.generation != 0 { + fields.push(("generation", Tag::Int(i32::from(w.generation)))); + } + fields + }), )); } if let Some((flight, explosions)) = &d.fireworks { - let list = explosions - .iter() - .map(|e| { - Tag::compound([ - ("shape", Tag::String(e.shape.name().into())), - ( - "colors", - Tag::IntArray(e.colors.iter().map(|c| *c as i32).collect()), - ), - ( - "fade_colors", - Tag::IntArray(e.fade.iter().map(|c| *c as i32).collect()), - ), - ("has_trail", Tag::Byte(i8::from(e.trail))), - ("has_twinkle", Tag::Byte(i8::from(e.twinkle))), - ]) - }) - .collect(); + let mut fields = Vec::new(); + if *flight != 0 { + fields.push(("flight_duration", Tag::Byte(*flight as i8))); + } + if !explosions.is_empty() { + fields.push(( + "explosions", + Tag::List(explosions.iter().map(explosion_tag).collect()), + )); + } + entries.push(("minecraft:fireworks", Tag::compound(fields))); + } + if let Some(e) = &d.firework_explosion { + entries.push(("minecraft:firework_explosion", explosion_tag(e))); + } + if let Some(color) = d.dyed_color { + entries.push(("minecraft:dyed_color", Tag::Int(color))); + } + if let Some(sides) = &d.pot_decorations { + let mut sherds = Vec::new(); + for (name, side) in ["back", "left", "right", "front"].iter().zip(sides.iter()) { + if let Some(stack) = side { + sherds.push((*name, template_tag(stack))); + } + } + entries.push(("minecraft:pot_decorations", Tag::compound(sherds))); + } + if !d.bundle_contents.is_empty() { entries.push(( - "minecraft:fireworks", - Tag::compound([ - ("flight_duration", Tag::Byte(*flight as i8)), - ("explosions", Tag::List(list)), - ]), + "minecraft:bundle_contents", + Tag::List(d.bundle_contents.iter().map(template_tag).collect()), + )); + } + if !d.stew_effects.is_empty() { + entries.push(( + "minecraft:suspicious_stew_effects", + Tag::List( + d.stew_effects + .iter() + .map(|(id, duration)| { + Tag::compound([ + ("id", Tag::String((&**id).into())), + ("duration", Tag::Int(*duration)), + ]) + }) + .collect(), + ), + )); + } + if let Some(mode) = d.map_post_processing { + entries.push(( + "minecraft:map_post_processing", + Tag::String(if mode == 0 { "lock" } else { "scale" }.into()), )); } if !d.charged.is_empty() { @@ -399,7 +589,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { .map(|(slot, stack)| { Tag::compound([ ("slot", Tag::Int(i32::from(*slot))), - ("item", crate::save::item_to_nbt(stack)), + ("item", template_tag(stack)), ]) }) .collect(); @@ -456,36 +646,54 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option Vec::new(), }, }), - fireworks: c.get("minecraft:fireworks").map(|f| { - let flight = f - .get("flight_duration") - .and_then(int) - .unwrap_or(0) - .clamp(0, 255) as u8; - let ints = |t: Option<&Tag>| match t { - Some(Tag::IntArray(v)) => v.iter().map(|c| *c as u32).collect(), - _ => Vec::new(), - }; - let explosions = match f.get("explosions") { - Some(Tag::List(list)) => list - .iter() - .map(|e| crate::particles::FireworkExplosion { - shape: crate::particles::FireworkShape::from_name( - e.get("shape") - .and_then(string) - .as_deref() - .unwrap_or("small_ball"), - ), - colors: ints(e.get("colors")), - fade: ints(e.get("fade_colors")), - trail: e.get("has_trail").and_then(int).unwrap_or(0) != 0, - twinkle: e.get("has_twinkle").and_then(int).unwrap_or(0) != 0, - }) - .collect(), - _ => Vec::new(), - }; - (flight, explosions) - }), + fireworks: c + .get("minecraft:fireworks") + .map(|f| { + let flight = f + .get("flight_duration") + .and_then(int) + .unwrap_or(0) + .clamp(0, 255) as u8; + let explosions = match f.get("explosions") { + Some(Tag::List(list)) => list.iter().map(explosion_from).collect(), + _ => Vec::new(), + }; + (flight, explosions) + }) + .filter(|(flight, explosions)| !(*flight == 1 && explosions.is_empty())), + firework_explosion: c.get("minecraft:firework_explosion").map(explosion_from), + dyed_color: c + .get("minecraft:dyed_color") + .and_then(int) + .map(|v| v as i32), + pot_decorations: c + .get("minecraft:pot_decorations") + .map(|p| { + let side = |name: &str| p.get(name).and_then(template_from); + Box::new([side("back"), side("left"), side("right"), side("front")]) + }) + .filter(|sides| !all_bricks(sides)), + bundle_contents: match c.get("minecraft:bundle_contents") { + Some(Tag::List(list)) => list.iter().filter_map(template_from).collect(), + _ => Vec::new(), + }, + stew_effects: match c.get("minecraft:suspicious_stew_effects") { + Some(Tag::List(list)) => list + .iter() + .filter_map(|e| { + Some(( + Arc::from(e.get("id").and_then(string)?.as_str()), + e.get("duration").and_then(int).unwrap_or(160) as i32, + )) + }) + .collect(), + _ => Vec::new(), + }, + map_post_processing: match c.get("minecraft:map_post_processing") { + Some(Tag::String(s)) => Some(u8::from(s.to_string_lossy() != "lock")), + Some(t) => int(t).map(|v| v.clamp(0, 1) as u8), + None => None, + }, charged: match c.get("minecraft:charged_projectiles") { Some(Tag::List(list)) => list.iter().filter_map(crate::save::item_from_nbt).collect(), _ => Vec::new(), @@ -496,17 +704,21 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option Vec::new(), }, - ominous_banner: matches!( - c.get("minecraft:banner_patterns"), - Some(Tag::List(layers)) if layers.first().is_some_and(|l| - l.get("pattern").and_then(string).as_deref() == Some("minecraft:rhombus")) - ), + ominous_banner: c + .get("minecraft:item_name") + .and_then(|n| n.get("translate")) + .and_then(string) + .as_deref() + == Some("block.minecraft.ominous_banner") + && matches!( + c.get("minecraft:banner_patterns"), + Some(Tag::List(layers)) if layers.len() == OMINOUS_LAYERS.len()), ominous_amplifier: c .get("minecraft:ominous_bottle_amplifier") .and_then(int) diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index c7eb83aae..bd0d44f92 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -60,7 +60,20 @@ pub struct ItemInfo { pub equip_sound: Option, /// `jukebox_playable`: the jukebox song it plays. pub jukebox_song: Option, + /// `crafting_remainder`: what stays in the grid when the item is used in a recipe. + pub crafting_remainder: Option, + /// `dye`: the dye colour name (dyes, and anything else with the component). + pub dye: Option, } +/// `Item.Properties.craftRemainder` (not a data component, so not in the +/// default-component report): what an item leaves in the grid when crafted with. +pub const CRAFT_REMAINDERS: [(&str, &str); 5] = [ + ("minecraft:water_bucket", "minecraft:bucket"), + ("minecraft:lava_bucket", "minecraft:bucket"), + ("minecraft:milk_bucket", "minecraft:bucket"), + ("minecraft:dragon_breath", "minecraft:glass_bottle"), + ("minecraft:honey_bottle", "minecraft:glass_bottle"), +]; /// A reduction of `blocks_attacks.damage_reductions`. #[derive(Clone, Debug)] pub struct BlockReduction { @@ -264,8 +277,6 @@ fn entity_spec(tags: &HashMap>, spec: &str) -> HashSet Result { - let raw: BTreeMap = - serde_json::from_slice(&fs::read(root.join("items.json"))?)?; let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new(); for (i, s) in facts.states.iter().enumerate() { let default = registry.default_state(&s.name); @@ -275,6 +286,16 @@ impl Items { .or_insert(BlockStateId(i as u32)); } } + Self::load_with(root, &block_for_item) + } + /// Every item's default components and the tags, without the block <-> + /// item mapping (for headless tools that never place blocks). + pub fn catalog(root: &Path) -> Result { + Self::load_with(root, &HashMap::new()) + } + fn load_with(root: &Path, block_for_item: &HashMap<&str, BlockStateId>) -> Result { + let raw: BTreeMap = + serde_json::from_slice(&fs::read(root.join("items.json"))?)?; let entity_tags = load_tags(root, "entity_type")?; let mut items = HashMap::new(); let mut order = Vec::new(); @@ -352,6 +373,11 @@ impl Items { .as_str() .map(str::to_owned), jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned), + dye: c["minecraft:dye"].as_str().map(str::to_owned), + crafting_remainder: CRAFT_REMAINDERS + .iter() + .find(|(item, _)| *item == name) + .map(|(_, rest)| (*rest).to_owned()), use_speed: c["minecraft:use_effects"]["speed_multiplier"] .as_f64() .unwrap_or(0.2) as f32, diff --git a/crates/horizon-client/src/recipe_book.rs b/crates/horizon-client/src/recipe_book.rs index 50edd8329..4aeb50f7c 100644 --- a/crates/horizon-client/src/recipe_book.rs +++ b/crates/horizon-client/src/recipe_book.rs @@ -287,7 +287,7 @@ impl Book { category: crafting_category(&category), group: group(&grp), display, - result: result.clone(), + result: result.create(), }); } for c in &recipes.cooking { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java b/mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java new file mode 100644 index 000000000..09d75a7d8 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java @@ -0,0 +1,132 @@ +package dev.horizonlab.parity; + +import java.lang.reflect.Field; +import java.util.*; +import net.minecraft.SharedConstants; +import net.minecraft.core.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.*; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.ReloadableServerRegistries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.tags.TagLoader; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +/** + * Shared set-up for the interaction/inventory parity oracles: the vanilla data + * pack loaded into registries (as the server does), an NBT registry ops, a + * stand-in ServerLevel that only answers registryAccess, and canonical text + * for item stacks and NBT so both sides print comparable lines. + */ +public final class ParityData { + public static HolderLookup.Provider lookup; + public static RegistryAccess access; + public static RegistryOps ops; + public static ServerLevel level; + + public static void init() throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var repo = ServerPacksSource.createVanillaTrustedRepository(); + repo.reload(); + repo.setSelected(List.of("vanilla")); + var resources = new MultiPackResourceManager(PackType.SERVER_DATA, repo.openAllSelected()); + var layers = RegistryLayer.createRegistryAccess(); + var staticTags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + var context = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), staticTags); + RegistryAccess.Frozen world = RegistryDataLoader.load(resources, context, RegistryDataLoader.WORLD_REGISTRIES, Runnable::run).join(); + var worldLayers = layers.replaceFrom(RegistryLayer.WORLD, world); + var loaded = ReloadableServerRegistries.reload(worldLayers, staticTags, resources, Runnable::run).join(); + lookup = loaded.lookupWithUpdatedTags(); + var components = BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(lookup); + staticTags.forEach(Registry.PendingTags::apply); + components.forEach(net.minecraft.core.component.DataComponentInitializers.PendingComponents::apply); + access = loaded.layers().compositeAccess(); + ops = RegistryOps.create(NbtOps.INSTANCE, lookup); + var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + level = (ServerLevel) unsafe.getClass().getMethod("allocateInstance", Class.class).invoke(unsafe, ServerLevel.class); + Field registryAccess = Level.class.getDeclaredField("registryAccess"); + registryAccess.setAccessible(true); + long offset = (long) unsafe.getClass().getMethod("objectFieldOffset", Field.class).invoke(unsafe, registryAccess); + unsafe.getClass().getMethod("putObject", Object.class, long.class, Object.class).invoke(unsafe, level, offset, access); + } + + /** "-" is the empty stack; otherwise ItemStack.CODEC over SNBT. */ + public static ItemStack stack(String snbt) throws Exception { + if (snbt.equals("-")) return ItemStack.EMPTY; + Tag tag = TagParser.parseCompoundFully(snbt); + return ItemStack.CODEC.parse(ops, tag).getOrThrow(); + } + + public static String canon(ItemStack stack) { + if (stack == null || stack.isEmpty()) return "-"; + Tag tag = ItemStack.CODEC.encodeStart(ops, stack).getOrThrow(); + return canon(tag); + } + + /** Sorted-key, type-annotated text of an NBT tag. */ + public static String canon(Tag tag) { + StringBuilder out = new StringBuilder(); + canon(tag, out); + return out.toString(); + } + + private static void canon(Tag tag, StringBuilder out) { + switch (tag) { + case ByteTag t -> out.append(t.value()).append('b'); + case ShortTag t -> out.append(t.value()).append('s'); + case IntTag t -> out.append(t.value()); + case LongTag t -> out.append(t.value()).append('L'); + case FloatTag t -> out.append(t.value()).append('f'); + case DoubleTag t -> out.append(t.value()).append('d'); + case StringTag t -> out.append('"').append(t.value().replace("\\", "\\\\").replace("\"", "\\\"")).append('"'); + case ByteArrayTag t -> { + out.append("[B;"); + byte[] a = t.getAsByteArray(); + for (int i = 0; i < a.length; i++) out.append(i > 0 ? "," : "").append(a[i]); + out.append(']'); + } + case IntArrayTag t -> { + out.append("[I;"); + int[] a = t.getAsIntArray(); + for (int i = 0; i < a.length; i++) out.append(i > 0 ? "," : "").append(a[i]); + out.append(']'); + } + case LongArrayTag t -> { + out.append("[L;"); + long[] a = t.getAsLongArray(); + for (int i = 0; i < a.length; i++) out.append(i > 0 ? "," : "").append(a[i]); + out.append(']'); + } + case ListTag t -> { + out.append('['); + for (int i = 0; i < t.size(); i++) { + if (i > 0) out.append(','); + canon(t.get(i), out); + } + out.append(']'); + } + case CompoundTag t -> { + out.append('{'); + boolean first = true; + for (String key : new TreeSet<>(t.keySet())) { + if (!first) out.append(','); + first = false; + out.append(key).append(':'); + canon(t.get(key), out); + } + out.append('}'); + } + default -> throw new IllegalStateException(tag.toString()); + } + } +} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeOracle.java new file mode 100644 index 000000000..f35cd5337 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeOracle.java @@ -0,0 +1,140 @@ +package dev.horizonlab.parity; + +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.*; + +/** + * Original oracle: vanilla's RecipeManager over the data pack's recipes. + * Tab-separated commands (stacks are SNBT of ItemStack.CODEC, "-" is empty): + * craft N cells..., cook TYPE stack, stonecut stack, smith template base addition. + * "dump" prints every crafting recipe's ingredient item lists for the generator. + */ +public final class VanillaRecipeOracle { + static RecipeManager recipes; + + public static void main(String[] args) throws Exception { + ParityData.init(); + recipes = new RecipeManager(ParityData.lookup); + recipes.finalizeRecipeLoading(FeatureFlags.VANILLA_SET); + if (args[0].equals("dump")) { + dump(Path.of(args[1])); + return; + } + try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)); + var lines = Files.lines(Path.of(args[0]))) { + for (String line : (Iterable) lines::iterator) { + String result; + try { + result = run(line.split("\t", -1)); + } catch (Exception e) { + result = "error"; + } + out.println(result); + } + } + } + + static String run(String[] f) throws Exception { + switch (f[0]) { + case "craft": { + int n = Integer.parseInt(f[1]); + List items = new ArrayList<>(); + for (int i = 0; i < n * n; i++) items.add(ParityData.stack(f[2 + i])); + var positioned = CraftingInput.ofPositioned(n, n, items); + var input = positioned.input(); + if (input.isEmpty()) return "none"; + var found = recipes.getRecipeFor(RecipeType.CRAFTING, input, null); + if (found.isEmpty()) return "none"; + var recipe = found.get().value(); + var result = recipe.assemble(input); + var rest = recipe.getRemainingItems(input); + StringJoiner joiner = new StringJoiner(";"); + for (int i = 0; i < rest.size(); i++) { + if (rest.get(i).isEmpty()) continue; + int at = (positioned.top() + i / input.width()) * n + positioned.left() + i % input.width(); + joiner.add(at + "=" + ParityData.canon(rest.get(i))); + } + return found.get().id().identifier().getPath() + "\t" + ParityData.canon(result) + "\t" + joiner; + } + case "cook": { + RecipeType type = switch (f[1]) { + case "smelting" -> RecipeType.SMELTING; + case "blasting" -> RecipeType.BLASTING; + case "smoking" -> RecipeType.SMOKING; + default -> RecipeType.CAMPFIRE_COOKING; + }; + return cook(type, ParityData.stack(f[2])); + } + case "stonecut": { + var stack = ParityData.stack(f[1]); + StringJoiner joiner = new StringJoiner(";"); + for (var entry : recipes.getSynchronizedStonecutterRecipes().selectByInput(stack).entries()) { + var holder = entry.recipe().recipe().get(); + joiner.add(holder.id().identifier().getPath() + "=" + ParityData.canon(holder.value().assemble(new SingleRecipeInput(stack)))); + } + return joiner.toString(); + } + case "smith": { + var input = new SmithingRecipeInput(ParityData.stack(f[1]), ParityData.stack(f[2]), ParityData.stack(f[3])); + var found = recipes.getRecipeFor(RecipeType.SMITHING, input, null); + if (found.isEmpty()) return "none"; + return ParityData.canon(found.get().value().assemble(input)); + } + default: + throw new IllegalArgumentException(f[0]); + } + } + + static String cook(RecipeType type, ItemStack stack) { + if (stack.isEmpty()) return "none"; + var input = new SingleRecipeInput(stack); + var found = recipes.getRecipeFor(type, input, null); + if (found.isEmpty()) return "none"; + var recipe = found.get().value(); + return found.get().id().identifier().getPath() + "\t" + ParityData.canon(recipe.assemble(input)) + "\t" + recipe.experience() + "\t" + recipe.cookingTime(); + } + + static String items(Ingredient ingredient) { + if (ingredient == null) return "[]"; + StringBuilder row = new StringBuilder("["); + var items = ingredient.items().toList(); + for (int i = 0; i < items.size(); i++) row.append(i > 0 ? "," : "").append('"').append(BuiltInRegistries.ITEM.getKey(items.get(i).value())).append('"'); + return row.append(']').toString(); + } + + static void dump(Path path) throws Exception { + try (var out = new PrintWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8))) { + for (var holder : recipes.getRecipes()) { + var recipe = holder.value(); + if (recipe instanceof SmithingRecipe smithing) { + out.println("{\"id\":\"" + holder.id().identifier().getPath() + "\",\"kind\":\"" + recipe.getClass().getSimpleName() + + "\",\"template\":" + items(smithing.templateIngredient().orElse(null)) + ",\"base\":" + items(smithing.baseIngredient()) + + ",\"addition\":" + items(smithing.additionIngredient().orElse(null)) + "}"); + continue; + } + if (recipe.getType() != RecipeType.CRAFTING) continue; + var info = recipe.placementInfo(); + StringBuilder row = new StringBuilder(); + row.append("{\"id\":\"").append(holder.id().identifier().getPath()).append("\",\"kind\":\"").append(recipe.getClass().getSimpleName()).append("\",\"slots\":["); + for (int i = 0; i < info.slotsToIngredientIndex().size(); i++) row.append(i > 0 ? "," : "").append(info.slotsToIngredientIndex().getInt(i)); + row.append("],\"ingredients\":["); + boolean firstIngredient = true; + for (var ingredient : info.ingredients()) { + row.append(firstIngredient ? "" : ",").append('['); + firstIngredient = false; + var items = ingredient.items().toList(); + for (int i = 0; i < items.size(); i++) row.append(i > 0 ? "," : "").append('"').append(BuiltInRegistries.ITEM.getKey(items.get(i).value())).append('"'); + row.append(']'); + } + row.append("]}"); + out.println(row); + } + } + } +} diff --git a/tools/interaction_lab.py b/tools/interaction_lab.py new file mode 100644 index 000000000..19cbd44bd --- /dev/null +++ b/tools/interaction_lab.py @@ -0,0 +1,71 @@ +"""Shared plumbing for the interaction/inventory differential harnesses +(test_recipe_parity, test_menu_click_parity, test_block_interaction_parity): +compile and run a vanilla Java oracle headlessly, run the matching Rust +example, and report differing lines grouped by a key.""" +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 + +RESOURCES = lab.LOCAL / 'horizon-client' / '26.3' +PARITY = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity' + + +def work_dir(name): + path = lab.LOCAL / name + path.mkdir(parents=True, exist_ok=True) + return path + + +class Vanilla: + """Compiles oracles against the pinned server jar and runs them.""" + + def __init__(self, work): + self.lock, self.jars, self.libraries = base.verify_artifacts() + self.work = work + self.classes = work / 'classes' + self.classes.mkdir(exist_ok=True) + self.deps = os.pathsep.join(map(str, [self.jars['server'], *self.libraries])) + + def compile(self, *oracles): + sources = [PARITY / (name + '.java') for name in oracles] + subprocess.run(['javac', '--release', str(self.lock['minecraft']['java_major']), '-proc:none', + '-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'), '-nowarn', + '-cp', self.deps, '-d', str(self.classes), *map(str, sources)], check=True) + + def run(self, oracle, *args, heap='2G'): + subprocess.run(['java', f'-Xmx{heap}', '-cp', str(self.classes) + os.pathsep + self.deps, + 'dev.horizonlab.parity.' + oracle, *map(str, args)], check=True) + + +def cargo_example(package, example, *args, profile=None): + command = ['cargo', 'run', '-q', '-j3'] + if profile: + command += ['--profile', profile] + command += ['-p', package, '--example', example, '--', *map(str, args)] + subprocess.run(command, check=True, cwd=lab.ROOT) + + +def compare(cases, native_path, rust_path, key, describe=None, limit=3): + """Print differing cases grouped by key(case, native_line); returns the count.""" + native = Path(native_path).read_text().split('\n') + rust = Path(rust_path).read_text().split('\n') + if len(native) != len(rust): + print(f'line counts differ: native {len(native)} rust {len(rust)}') + groups = {} + for case, a, b in zip(cases, native, rust): + if a != b: + groups.setdefault(key(case, a), []).append((case, a, b)) + describe = describe or (lambda case: case if isinstance(case, str) else json.dumps(case)) + total = sum(len(v) for v in groups.values()) + for name, entries in sorted(groups.items(), key=lambda e: -len(e[1])): + print(f'{name}: {len(entries)} differing') + for case, a, b in entries[:limit]: + print(f' case {describe(case)}\n native {a}\n rust {b}') + print(f'{len(cases) - total}/{len(cases)} cases match') + return total diff --git a/tools/test_recipe_parity.py b/tools/test_recipe_parity.py new file mode 100644 index 000000000..5f96e164e --- /dev/null +++ b/tools/test_recipe_parity.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Compare crafting, cooking, stonecutting and smithing with vanilla's RecipeManager. + +The Java oracle (VanillaRecipeOracle) loads the data pack's recipe registry and +answers craft/cook/stonecut/smith commands; the Rust example (recipe_fixture) +answers the same commands from horizon_client::crafting and ::stations. +Crafting cases are built from every recipe's own ingredients (placed at random +offsets, mirrored, perturbed) plus focused generators for the special recipes +(banners, books, fireworks, dyeing, repair, pots, tipped arrows, maps) and +random grids. Results are compared as canonical ItemStack NBT, with the +matched recipe id and the crafting remainders. +""" +import argparse +import json +import random +try: + from . import interaction_lab as lib, lab +except ImportError: + import interaction_lab as lib + import lab + +DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple', + 'blue', 'brown', 'green', 'red', 'black'] +POTIONS = ['minecraft:healing', 'minecraft:swiftness', 'minecraft:water', 'minecraft:night_vision'] +SHAPES = ['small_ball', 'large_ball', 'star', 'creeper', 'burst'] + + +def quote(text): + return '"' + text.replace('\\', '\\\\').replace('"', '\\"') + '"' + + +def stack(item, count=1, **components): + """SNBT of an ItemStack.CODEC compound; component values are SNBT text.""" + body = ','.join(f'{quote("minecraft:" + key)}:{value}' for key, value in sorted(components.items())) + comp = f',components:{{{body}}}' if body else '' + return f'{{id:{quote(item)},count:{count}{comp}}}' + + +def colors(rng, n=None): + n = rng.randrange(0, 4) if n is None else n + return '[I;' + ','.join(str(rng.randrange(1 << 24)) for _ in range(n)) + ']' + + +def explosion(rng): + return ('{shape:%s,colors:%s,fade_colors:%s,has_trail:%db,has_twinkle:%db}' + % (quote(rng.choice(SHAPES)), colors(rng), colors(rng), rng.randrange(2), rng.randrange(2))) + + +class Gen: + def __init__(self, rng, items, recipes, patterns): + self.rng = rng + self.items = items + self.recipes = recipes + self.patterns = patterns + self.durable = [k for k, v in items.items() if v.get('minecraft:max_damage')] + + def layers(self, n=None): + n = self.rng.choice([0, 0, 1, 2, 6, 7]) if n is None else n + return '[' + ','.join('{pattern:%s,color:%s}' % (quote(self.rng.choice(self.patterns)), quote(self.rng.choice(DYES))) for _ in range(n)) + ']' + + def book(self, generation=None): + g = self.rng.randrange(4) if generation is None else generation + return stack('minecraft:written_book', written_book_content='{title:{raw:"T"},author:"A",generation:%d,pages:[{raw:"p1"},{raw:"p2"}]}' % g) + + def stack(self, item, count=None, rich=0.5): + """A stack of the item, with the components that apply to it.""" + rng = self.rng + info = self.items.get(item, {}) + count = rng.choice([1, 1, 1, 2, 3]) if count is None else count + c = {} + if rng.random() < rich: + if info.get('minecraft:max_damage') and rng.random() < 0.7: + c['damage'] = str(rng.randrange(0, info['minecraft:max_damage'] + 1)) + if info.get('minecraft:max_damage') and rng.random() < 0.3: + c['enchantments'] = '{%s:1}' % quote(rng.choice(['minecraft:binding_curse', 'minecraft:vanishing_curse', 'minecraft:mending', 'minecraft:unbreaking'])) + if item.endswith('_banner'): + c['banner_patterns'] = self.layers() + if item == 'minecraft:shield': + if rng.random() < 0.3: + c['banner_patterns'] = self.layers(rng.randrange(1, 3)) + c['base_color'] = quote(rng.choice(DYES)) + if item == 'minecraft:written_book': + return self.book() + if item == 'minecraft:firework_star': + c['firework_explosion'] = explosion(rng) + if item == 'minecraft:firework_rocket': + c['fireworks'] = '{flight_duration:%db,explosions:[%s]}' % (rng.randrange(1, 4), explosion(rng)) + if item in ('minecraft:lingering_potion', 'minecraft:potion', 'minecraft:splash_potion'): + c['potion_contents'] = '{potion:%s}' % quote(rng.choice(POTIONS)) + if item.startswith('minecraft:leather_') or item in ('minecraft:wolf_armor',): + if rng.random() < 0.6: + c['dyed_color'] = str(rng.randrange(1 << 24)) + if item.endswith('shulker_box'): + c['container'] = '[{slot:0,item:{id:"minecraft:stone",count:%d}}]' % rng.randrange(1, 64) + if item.endswith('_bundle') or item == 'minecraft:bundle': + c['bundle_contents'] = '[{id:"minecraft:stone",count:%d}]' % rng.randrange(1, 64) + if item == 'minecraft:filled_map': + c['map_id'] = str(rng.randrange(1, 5)) + return stack(item, count, **c) + + def plain(self, item): + return self.stack(item, rich=0.3) + + +def place(rng, cells, size): + """Cells (stack list) at random free positions of a size x size grid.""" + grid = ['-'] * (size * size) + spots = rng.sample(range(size * size), len(cells)) + for spot, cell in zip(spots, cells): + grid[spot] = cell + return grid + + +def place_box(rng, width, height, cells, size): + """A width x height pattern at a random offset of the grid.""" + x0 = rng.randrange(size - width + 1) + y0 = rng.randrange(size - height + 1) + grid = ['-'] * (size * size) + for i, cell in enumerate(cells): + grid[(y0 + i // width) * size + x0 + i % width] = cell + return grid + + +def craft_case(grid, size): + return 'craft\t%d\t%s' % (size, '\t'.join(grid)) + + +def recipe_cases(g, count_per): + rng = g.rng + for recipe in g.recipes: + if 'slots' not in recipe or not recipe['ingredients']: + continue + ingredients = recipe['ingredients'] + slots = recipe['slots'] + shaped = recipe['kind'] == 'ShapedRecipe' + if shaped: + width = recipe['width'] + height = len(slots) // width + for variant in range(count_per): + pick = [g.plain(rng.choice(i)) if i else '-' for i in ingredients] + if shaped: + cells = ['-' if s < 0 else pick[s] for s in slots] + if variant % 3 == 1: # mirrored + cells = [cells[y * width + width - 1 - x] for y in range(height) for x in range(width)] + size = rng.choice([s for s in (2, 3) if s >= max(width, height)]) + grid = place_box(rng, width, height, cells, size) + else: + size = 3 if len(pick) > 4 else rng.choice([2, 3]) + grid = place(rng, pick, size) + yield craft_case(grid, size) + # Perturbations: drop one, swap one for a stranger, add one, shift a count. + if variant < 2: + filled = [i for i, c in enumerate(grid) if c != '-'] + broken = list(grid) + broken[rng.choice(filled)] = '-' + yield craft_case(broken, size) + broken = list(grid) + broken[rng.choice(filled)] = g.plain(rng.choice([i for i in g.items if i != 'minecraft:air'])) + yield craft_case(broken, size) + free = [i for i, c in enumerate(grid) if c == '-'] + if free: + broken = list(grid) + broken[rng.choice(free)] = g.plain(rng.choice([i for i in g.items if i != 'minecraft:air'])) + yield craft_case(broken, size) + + +def special_cases(g, n): + rng = g.rng + items = g.items + banners = [f'minecraft:{d}_banner' for d in DYES] + dyes = [f'minecraft:{d}_dye' for d in DYES] + armor = [k for k in items if k.startswith('minecraft:leather_') and k.count('_') == 2] + ['minecraft:wolf_armor'] + boxes = [k for k in items if k.endswith('shulker_box')] + bundles = [k for k in items if k.endswith('bundle')] + skulls = [k for k in items if k.endswith('_head') or k.endswith('_skull')] + for _ in range(n): + size = rng.choice([2, 3]) + # banner duplicate / shield decoration + a, b = rng.choice(banners), rng.choice(banners) + yield craft_case(place(rng, [g.stack(a, 1, 1.0), g.stack(b if rng.random() < 0.3 else a, 1, 0.5)], size), size) + yield craft_case(place(rng, [g.stack(rng.choice(banners), 1, 1.0), g.stack('minecraft:shield', 1, 0.5)], size), size) + # book cloning + books = [g.book(rng.choice([0, 0, 1, 2, 3]))] + [stack('minecraft:writable_book') for _ in range(rng.randrange(0, 4))] + if rng.random() < 0.2: + books.append(g.book()) + yield craft_case(place(rng, books, 3), 3) + # fireworks + star = [stack(rng.choice(['minecraft:gunpowder'] + dyes[:2] + ['minecraft:gold_nugget', 'minecraft:feather', 'minecraft:fire_charge', + 'minecraft:diamond', 'minecraft:glowstone_dust', rng.choice(skulls)])) + for _ in range(rng.randrange(1, 7))] + star += [stack('minecraft:gunpowder'), stack(rng.choice(dyes))] + yield craft_case(place(rng, star[:9], 3), 3) + fade = [g.stack('minecraft:firework_star', 1, 1.0)] + [stack(rng.choice(dyes)) for _ in range(rng.randrange(1, 4))] + yield craft_case(place(rng, fade, 3), 3) + rocket = [stack('minecraft:paper')] + [stack('minecraft:gunpowder') for _ in range(rng.randrange(0, 5))] + \ + [g.stack('minecraft:firework_star', 1, 1.0) for _ in range(rng.randrange(0, 4))] + yield craft_case(place(rng, rocket[:9], 3), 3) + # armour dyeing, with leftovers + dye_cells = [g.stack(rng.choice(armor), 1, 0.7)] + [stack(rng.choice(dyes + ['minecraft:stick'] if rng.random() < 0.1 else dyes)) for _ in range(rng.randrange(1, 5))] + yield craft_case(place(rng, dye_cells[:9], 3), 3) + # repair + tool = rng.choice(g.durable) + pair = [g.stack(tool, 1, 1.0), g.stack(tool if rng.random() < 0.85 else rng.choice(g.durable), 1, 1.0)] + yield craft_case(place(rng, pair, size), size) + # shulker boxes and bundles + yield craft_case(place(rng, [g.stack(rng.choice(boxes + bundles), 1, 1.0), stack(rng.choice(dyes))], size), size) + multi = [g.stack(rng.choice(boxes + bundles), 1, 1.0)] + [stack(rng.choice(dyes)) for _ in range(rng.randrange(1, 4))] + yield craft_case(place(rng, multi, 3), 3) + # decorated pots + sherds = ['minecraft:brick', 'minecraft:angler_pottery_sherd', 'minecraft:archer_pottery_sherd', 'minecraft:stick', 'minecraft:blade_pottery_sherd'] + pot = ['-', stack(rng.choice(sherds)), '-', stack(rng.choice(sherds)), '-', stack(rng.choice(sherds)), '-', stack(rng.choice(sherds)), '-'] + if rng.random() < 0.2: + pot[rng.choice([0, 2, 6, 8, 4])] = stack('minecraft:brick') + yield craft_case(pot, 3) + yield craft_case([stack('minecraft:brick') if i in (1, 3, 5, 7) else '-' for i in range(9)], 3) + # tipped arrows + ring = [stack('minecraft:arrow', rng.choice([1, 2]))] * 9 + ring[4] = g.stack('minecraft:lingering_potion', 1, 1.0 if rng.random() < 0.8 else 0.0) + yield craft_case(ring, 3) + # map cloning + maps = [g.stack('minecraft:filled_map', 1, 1.0)] + [stack('minecraft:map') for _ in range(rng.randrange(1, 9))] + yield craft_case(place(rng, maps, 3), 3) + # mixed junk of the interesting items + pool = banners + dyes + armor + boxes + bundles + ['minecraft:written_book', 'minecraft:writable_book', 'minecraft:firework_star', + 'minecraft:paper', 'minecraft:gunpowder', 'minecraft:shield', 'minecraft:glass_bottle', + 'minecraft:milk_bucket', 'minecraft:honey_bottle', 'minecraft:bowl', 'minecraft:stick'] + junk = [g.stack(rng.choice(pool), None, 0.7) for _ in range(rng.randrange(1, 6))] + yield craft_case(place(rng, junk, 3), 3) + + +def smithing_cases(g): + rng = g.rng + for recipe in g.recipes: + if recipe['kind'].startswith('Smithing'): + for _ in range(24): + pick = lambda key: g.plain(rng.choice(recipe[key])) if recipe[key] else '-' + yield 'smith\t%s\t%s\t%s' % (pick('template'), pick('base'), pick('addition')) + yield 'smith\t%s\t%s\t%s' % (pick('template'), pick('base'), g.plain(rng.choice([i for i in g.items if i != 'minecraft:air']))) + # Armour with a trim already on it. + trims = ['{material:"minecraft:iron",pattern:"minecraft:coast"}', '{material:"minecraft:gold",pattern:"minecraft:sentry"}'] + for recipe in g.recipes: + if recipe['kind'] == 'SmithingTrimRecipe': + for trim in trims: + base = rng.choice(recipe['base']) + yield 'smith\t%s\t%s\t%s' % (stack(rng.choice(recipe['template'])), stack(base, 1, trim=trim), stack(rng.choice(recipe['addition']))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--seed', type=int, default=7) + parser.add_argument('--variants', type=int, default=6) + parser.add_argument('--specials', type=int, default=1500) + parser.add_argument('--random', type=int, default=4000) + parser.add_argument('--limit', type=int, default=3) + args = parser.parse_args() + work = lib.work_dir('recipe-parity') + vanilla = lib.Vanilla(work) + vanilla.compile('ParityData', 'VanillaRecipeOracle') + vanilla.run('VanillaRecipeOracle', 'dump', work / 'recipes.jsonl') + recipes = [json.loads(line) for line in (work / 'recipes.jsonl').read_text().splitlines()] + # Shaped recipes: widths come from the data pack. + for recipe in recipes: + path = lib.RESOURCES / 'data/minecraft/recipe' / (recipe['id'] + '.json') + data = json.loads(path.read_text()) + if recipe['kind'] == 'ShapedRecipe': + rows = [row.rstrip() for row in data['pattern']] + left = min(len(row) - len(row.lstrip()) for row in rows if row) + recipe['width'] = max(len(row) for row in rows) - left + items = json.loads((lib.RESOURCES / 'items.json').read_text()) + patterns = sorted('minecraft:' + p.stem for p in (lib.RESOURCES / 'data/minecraft/banner_pattern').glob('*.json')) + rng = random.Random(args.seed) + g = Gen(rng, items, recipes, patterns) + cases = list(recipe_cases(g, args.variants)) + cases += list(special_cases(g, args.specials)) + pool = [k for k in items if not k.endswith('_spawn_egg') and k != 'minecraft:air'] + interesting = [f'minecraft:{d}_dye' for d in DYES] + ['minecraft:paper', 'minecraft:gunpowder', 'minecraft:bowl', 'minecraft:stick', + 'minecraft:milk_bucket', 'minecraft:honey_bottle', 'minecraft:water_bucket'] + for _ in range(args.random): + size = rng.choice([2, 3]) + cells = [g.stack(rng.choice(interesting if rng.random() < 0.6 else pool), None, 0.5) for _ in range(rng.randrange(1, size * size + 1))] + cases.append(craft_case(place(rng, cells, size), size)) + for kind in ['smelting', 'blasting', 'smoking', 'campfire_cooking']: + for item in [i for i in items if i != 'minecraft:air']: + cases.append('cook\t%s\t%s' % (kind, stack(item))) + for item in [i for i in items if i != 'minecraft:air']: + cases.append('stonecut\t' + stack(item)) + cases += list(smithing_cases(g)) + (work / 'cases.txt').write_text(''.join(c + '\n' for c in cases)) + vanilla.run('VanillaRecipeOracle', work / 'cases.txt', work / 'native.txt') + lib.cargo_example('horizon-client', 'recipe_fixture', lib.RESOURCES, work / 'cases.txt', work / 'rust.txt') + + def key(case, native_line): + kind = case.split('\t')[0] + return kind if kind != 'craft' else 'craft ' + native_line.split('\t')[0] + + def describe(case): + return case if len(case) < 1200 else case[:1200] + '...' + total = lib.compare(cases, work / 'native.txt', work / 'rust.txt', key, describe, args.limit) + raise SystemExit(1 if total else 0) + + +if __name__ == '__main__': + main()