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()