diff --git a/crates/horizon-client/examples/probe/brewing_fixture.rs b/crates/horizon-client/examples/probe/brewing_fixture.rs new file mode 100644 index 0000000000..58dec3dbf4 --- /dev/null +++ b/crates/horizon-client/examples/probe/brewing_fixture.rs @@ -0,0 +1,96 @@ +//! Brewing parity (tools/test_brewing_parity.py): reads "input reagent +//! candidate" lines of item SNBT and prints the three brewing-slot filters, +//! the matching recipe and its assembled result; "tick" lines build a +//! BrewingStand, run it for their tick count, and print the same state the +//! real BrewingStandBlockEntity.serverTick oracle prints. +//! +//! The first input line is `#resources RESOURCES`; usage: brewing_fixture INPUT OUTPUT +use horizon_client::{ + brewing::{Brewing, BrewingStand, brewing_fuel}, + item_data::{canon_stack, parse_stack}, + items::{ItemStack, Items}, +}; +use horizon_client_render::providers::Providers; +use horizon_world::loot::PackOverlay; +use std::sync::Arc; +use std::{fmt::Write as _, fs}; + +pub fn main() -> horizon_client::Result<()> { + let args: Vec = crate::args().collect(); + let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?; + let root = input + .lines() + .find_map(|line| line.strip_prefix("#resources ")) + .map(std::path::PathBuf::from) + .ok_or("no #resources line")?; + let items = Items::catalog(&root, &PackOverlay::NONE)?; + let brewing = Brewing::load(&root, &items, &PackOverlay::NONE); + let providers = Providers::load(&root); + let mut out = String::new(); + for (n, line) in input + .lines() + .filter(|line| !line.starts_with('#')) + .enumerate() + { + let f: Vec<&str> = line.split(' ').collect(); + if f[0] == "tick" { + let ticks: u32 = f[1].parse()?; + let mut slots: [Option; 5] = Default::default(); + for i in 0..5 { + slots[i] = parse_stack(f[2 + i])?; + } + let mut stand = BrewingStand::new(); + stand.items = slots; + stand.brew_time = f[7].parse()?; + stand.fuel = f[8].parse()?; + stand.total_brew_time = f[9].parse()?; + stand.total_fuel = f[10].parse()?; + stand.speed = f[11].parse()?; + stand.ingredient = (f[12] != "-").then(|| Arc::::from(f[12])); + let mut brews = 0; + for _ in 0..ticks { + brews += stand.tick(&brewing, &items, &providers).brewed as u32; + } + let bits: String = stand.items[..3] + .iter() + .map(|s| if s.is_some() { '1' } else { '0' }) + .collect(); + writeln!( + out, + "{n} s0={} s1={} s2={} s3={} s4={} time={} fuel={} total={} totalfuel={} speed={:.3} bits={bits} ing={} brews={brews}", + canon_stack(stand.items[0].as_ref()), + canon_stack(stand.items[1].as_ref()), + canon_stack(stand.items[2].as_ref()), + canon_stack(stand.items[3].as_ref()), + canon_stack(stand.items[4].as_ref()), + stand.brew_time, + stand.fuel, + stand.total_brew_time, + stand.total_fuel, + stand.speed, + stand.ingredient.as_deref().unwrap_or("-"), + )?; + continue; + } + let input = parse_stack(f[0])?; + let reagent = parse_stack(f[1])?; + let candidate = parse_stack(f[2])?; + let potion_slot = candidate.as_ref().is_some_and(|s| brewing.is_bottle(s)); + let reagent_slot = candidate.as_ref().is_some_and(|s| brewing.is_reagent(s)); + let fuel_slot = candidate + .as_ref() + .is_some_and(|s| brewing_fuel(&items, s).is_some()); + let found = match (input.as_ref(), reagent.as_ref()) { + (Some(input), Some(reagent)) => brewing.find(input, reagent), + _ => None, + }; + let result = found.map_or_else(|| "-".to_owned(), |r| canon_stack(Some(&r.output))); + let id = found.map_or_else(|| "-".to_owned(), |r| r.id.clone()); + writeln!( + out, + "{n} {potion_slot} {reagent_slot} {fuel_slot} {result} {id}" + )?; + } + fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?; + Ok(()) +} diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index 7cfd7320b9..4903c4a3c7 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -24,6 +24,7 @@ mod block_particles_fixture; mod block_tint_probe; mod block_transform_fixture; mod breaking_fixture; +mod brewing_fixture; mod camera_probe; mod cauldron_fixture; mod chat_screen_fixture; @@ -127,6 +128,7 @@ probes! { block_entity_tickers_fixture, block_particles_fixture, block_transform_fixture, + brewing_fixture, breaking_fixture, block_tint_probe, camera_probe, diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java new file mode 100644 index 0000000000..d95d3e0173 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java @@ -0,0 +1,156 @@ +package dev.horizonlab.parity; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.PrintWriter; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BrewingStandBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.item.crafting.BrewingInput; +import net.minecraft.world.item.crafting.BrewingRecipe; +import net.minecraft.world.item.crafting.PotionIngredient; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.RecipePropertySet; +import net.minecraft.world.item.crafting.RecipeType; + +/** + * Original oracle for the brewing recipes: a real `RecipeManager` over the + * vanilla data pack, its `brewing_input`/`brewing_reagent` property sets, the + * three brewing-slot filters, and the first matching `BrewingRecipe`'s + * `assemble`. Fixture lines are "input-snbt reagent-snbt candidate-snbt"; + * "tick" lines build a brewing stand in a block-update world and call the real + * `BrewingStandBlockEntity.serverTick`, printing the slots, brew time, fuel, + * speed, bottle bits, ingredient and brew-event count. + */ +public final class VanillaBrewingOracle { + @SuppressWarnings("unchecked") + public static void main(String[] args) throws Exception { + ParityData.init(); + VanillaBlockUpdateOracle.AIR = Blocks.AIR.defaultBlockState(); + var allocator = Class.forName("sun.misc.Unsafe"); + var unsafeField = allocator.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + VanillaBlockUpdateOracle.unsafe = unsafeField.get(null); + VanillaBlockUpdateOracle.allocate = allocator.getMethod("allocateInstance", Class.class); + var recipes = new RecipeManager(ParityData.lookup); + recipes.finalizeRecipeLoading(FeatureFlags.VANILLA_SET); + VanillaBlockUpdateOracle.Server.recipes = recipes; + List> brewing = recipes.getRecipes().stream() + .filter(h -> h.value() instanceof BrewingRecipe) + .map(h -> (RecipeHolder) h) + .toList(); + var inputs = recipes.propertySet(RecipePropertySet.BREWING_INPUTS); + var reagents = recipes.propertySet(RecipePropertySet.BREWING_REAGENTS); + Path in = Path.of(args[0]), out = Path.of(args[1]); + int n = 0; + try (var w = new PrintWriter(Files.newBufferedWriter(out, StandardCharsets.UTF_8)); + var lines = Files.newBufferedReader(in, StandardCharsets.UTF_8)) { + for (String line = lines.readLine(); line != null; line = lines.readLine()) { + if (line.startsWith("#")) continue; + String[] f = line.split(" "); + if (f[0].equals("tick")) { + w.println(tick(n, f, recipes)); + n++; + continue; + } + ItemStack input = ParityData.stack(f[0]); + ItemStack reagent = ParityData.stack(f[1]); + ItemStack candidate = ParityData.stack(f[2]); + boolean potionSlot = !candidate.isEmpty() && PotionIngredient.isPotionInput(candidate, recipes); + boolean reagentSlot = !candidate.isEmpty() && reagents.test(candidate); + boolean fuelSlot = !candidate.isEmpty() && candidate.has(DataComponents.BREWING_FUEL); + var brewingInput = new BrewingInput(input, reagent); + Optional> found = brewing.stream() + .filter(h -> h.value().matches(brewingInput)) + .findFirst(); + String result = found.map(h -> ParityData.canon(h.value().assemble(brewingInput))).orElse("-"); + String id = found.map(h -> h.id().identifier().toString()).orElse("-"); + w.println(n + " " + potionSlot + " " + reagentSlot + " " + fuelSlot + " " + result + " " + id); + n++; + } + } + System.exit(0); + } + + private static Field field(Class c, String name) throws Exception { + Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f; + } + + private static void setInt(Object o, Class c, String name, int v) throws Exception { + field(c, name).setInt(o, v); + } + + /** One brewing-stand tick run: initial slots/fields, `ticks` serverTick calls, final state. */ + private static String tick(int n, String[] f, RecipeManager recipes) throws Exception { + int ticks = Integer.parseInt(f[1]); + ItemStack[] slots = new ItemStack[5]; + for (int i = 0; i < 5; i++) slots[i] = ParityData.stack(f[2 + i]); + int brewTime = Integer.parseInt(f[7]); + int fuel = Integer.parseInt(f[8]); + int totalBrew = Integer.parseInt(f[9]); + int totalFuel = Integer.parseInt(f[10]); + float speed = Float.parseFloat(f[11]); + String ingredient = f[12]; + + JsonObject caseJson = new JsonObject(); + JsonArray cells = new JsonArray(); + JsonArray cell = new JsonArray(); + cell.add(0); + cell.add(0); + cell.add(0); + cell.add(Block.getId(Blocks.BREWING_STAND.defaultBlockState())); + cells.add(cell); + caseJson.add("cells", cells); + caseJson.addProperty("seed", 0); + VanillaBlockUpdateOracle.World w = VanillaBlockUpdateOracle.build(caseJson); + w.registries = ParityData.access; + var pos = new BlockPos(0, 0, 0); + BlockState state = w.getBlockState(pos); + var be = (BrewingStandBlockEntity) w.getBlockEntity(pos); + for (int i = 0; i < 5; i++) be.setItem(i, slots[i]); + setInt(be, BrewingStandBlockEntity.class, "brewTime", brewTime); + setInt(be, BrewingStandBlockEntity.class, "fuel", fuel); + setInt(be, BrewingStandBlockEntity.class, "totalBrewTime", totalBrew); + setInt(be, BrewingStandBlockEntity.class, "totalFuel", totalFuel); + field(BrewingStandBlockEntity.class, "speedMultiplier").setFloat(be, speed); + Item ingredientItem = ingredient.equals("-") ? null + : BuiltInRegistries.ITEM.getValue(Identifier.parse(ingredient)); + field(BrewingStandBlockEntity.class, "ingredient").set(be, ingredientItem); + for (int t = 0; t < ticks; t++) { + BrewingStandBlockEntity.serverTick(w, pos, state, be); + } + int brews = 0; + for (int at = w.log.indexOf("L 1035 "); at >= 0; at = w.log.indexOf("L 1035 ", at + 1)) brews++; + StringBuilder sb = new StringBuilder(Integer.toString(n)); + for (int i = 0; i < 5; i++) sb.append(" s").append(i).append('=').append(ParityData.canon(be.getItem(i))); + sb.append(" time=").append(field(BrewingStandBlockEntity.class, "brewTime").getInt(be)) + .append(" fuel=").append(field(BrewingStandBlockEntity.class, "fuel").getInt(be)) + .append(" total=").append(field(BrewingStandBlockEntity.class, "totalBrewTime").getInt(be)) + .append(" totalfuel=").append(field(BrewingStandBlockEntity.class, "totalFuel").getInt(be)) + .append(" speed=").append(String.format(Locale.ROOT, "%.3f", + field(BrewingStandBlockEntity.class, "speedMultiplier").getFloat(be))) + .append(" bits="); + for (int i = 0; i < 3; i++) sb.append(be.getItem(i).isEmpty() ? '0' : '1'); + Item ing = (Item) field(BrewingStandBlockEntity.class, "ingredient").get(be); + sb.append(" ing=").append(ing == null ? "-" : BuiltInRegistries.ITEM.getKey(ing).toString()) + .append(" brews=").append(brews); + return sb.toString(); + } +} diff --git a/tools/test_brewing_parity.py b/tools/test_brewing_parity.py new file mode 100644 index 0000000000..56886b54ad --- /dev/null +++ b/tools/test_brewing_parity.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Compare the brewing recipes' slot rules, matching and assembled output with vanilla. + +The Java oracle loads the vanilla data pack's real `RecipeManager`, takes its +`brewing_input` and `brewing_reagent` property sets and the `BrewingRecipe`s, +checks all three brewing-slot filters and calls the first matching recipe's +`assemble`. The Rust probe calls `brewing::Brewing::is_bottle`/`is_reagent`, +`brewing_fuel` and `Brewing::find`. Cases cover every bundled brewing recipe, +every potion type and potion id against every reagent, candidates from the +whole item registry, and non-matching pairs. Tick cases run +`BrewingStandBlockEntity.serverTick` and `BrewingStand::tick` for 0..8001 +ticks: the 400-tick brew time divided by `speed_multiplier` and its ceiling, +blaze powder's 20 fuel uses, fuel loaded with nothing to brew, splash and +lingering outputs, ingredient shrink and mismatch, the brew sound, and the +bottle bits. `--no-tick` leaves the tick corpus out, and `--baseline` keeps +only the exact recipe pairs. +""" +import json +from pathlib import Path +import sys +try: + from . import lab, oracle_harness as harness +except ImportError: + import lab + import oracle_harness as harness + +POTION_ITEMS = ['potion', 'splash_potion', 'lingering_potion'] + + +def stack(item, potion=None, count=1): + if potion: + return '{id:"%s",count:%d,components:{"minecraft:potion_contents":{potion:"%s"}}}' % ( + item, count, potion) + return '{id:"%s",count:%d}' % (item, count) + + +def recipes(resources): + out = [] + for path in sorted((resources / 'data/minecraft/recipe/brewing').glob('*.json')): + data = json.loads(path.read_text()) + if data.get('type') != 'minecraft:brewing': + continue + potion = data['input'].get('potion_contents', {}).get('potions') + out.append({ + 'input': stack(data['input']['item'], potion), + 'reagent': stack(data['reagent']['item']), + 'output': data['output'], + }) + return out + + +def potion(kind='potion', name='water', count=1): + return '{id:"minecraft:%s",count:%d,components:{"minecraft:potion_contents":{potion:"minecraft:%s"}}}' % ( + kind, count, name) + + +def tick_case(ticks, slots, brew_time=0, fuel=0, total=0, total_fuel=20, speed=1.0, + ingredient='-'): + filled = [slot if slot else '-' for slot in slots] + return 'tick %d %s %d %d %d %d %s %s' % ( + ticks, ' '.join(filled), brew_time, fuel, total, total_fuel, speed, ingredient) + + +def tick_cases(): + water = potion() + awkward = potion(name='awkward') + splash = potion('splash_potion') + lingering = potion('lingering_potion') + wart = stack('minecraft:nether_wart') + wart2 = stack('minecraft:nether_wart', count=2) + wart5 = stack('minecraft:nether_wart', count=5) + wart21 = stack('minecraft:nether_wart', count=21) + blaze = stack('minecraft:blaze_powder') + blaze2 = '{id:"minecraft:blaze_powder",count:2}' + redstone = stack('minecraft:redstone') + out = [] + # Brew-time countdown, with fuel already in the tank. + for ticks in (0, 1, 2, 399, 400, 401, 402, 800): + out.append(tick_case(ticks, [water, None, None, wart, None], fuel=20, + total=400, ingredient='minecraft:nether_wart')) + # Blaze powder loads the tank (uses=20, speed=1), then the brew runs. + for ticks in (0, 1, 2, 399, 400, 401): + out.append(tick_case(ticks, [water, None, None, wart, blaze], total=0, + ingredient='minecraft:nether_wart')) + out.append(tick_case(1, [water, None, None, wart, blaze2], total=0, + ingredient='minecraft:nether_wart')) + # Fuel loads even with nothing to brew. + out.append(tick_case(1, [None, None, None, wart, blaze], total=0, + ingredient='minecraft:nether_wart')) + out.append(tick_case(1, [None, None, None, None, blaze], total=0)) + # Brew time is ceil(400 / speed) and speed 0 falls back to 1. + for speed, finish in ((2.0, 200), (3.0, 134), (0.3, 1334), (0.0, 400)): + for ticks in (1, finish, finish + 1, finish + 2): + out.append(tick_case(ticks, [water, None, None, wart, None], fuel=5, + total=0, speed=speed, ingredient='minecraft:nether_wart')) + # Only the slots with a recipe change; the no-recipe bottle stays. + for ticks in (1, 400, 401): + out.append(tick_case(ticks, [water, awkward, water, wart, None], fuel=20, + total=400, ingredient='minecraft:nether_wart')) + # The ingredient stack shrinks; the last one leaves an empty slot. + for wart_stack, ticks in ((wart2, 400), (wart5, 400), (stack('minecraft:nether_wart'), 400)): + out.append(tick_case(ticks, [water, None, None, wart_stack, None], fuel=20, + total=400, ingredient='minecraft:nether_wart')) + # Changing the ingredient mid-brew clears the progress; finishing does not need fuel. + out.append(tick_case(1, [water, None, None, redstone, None], brew_time=200, fuel=5, + total=400, ingredient='minecraft:nether_wart')) + out.append(tick_case(1, [water, None, None, wart, None], brew_time=1, fuel=0, + total=400, ingredient='minecraft:nether_wart')) + out.append(tick_case(1, [water, None, None, None, None], brew_time=1, fuel=5, + total=400, ingredient='minecraft:nether_wart')) + # Splash and lingering potions keep their kind through the mix. + out.append(tick_case(401, [splash, None, None, wart, None], fuel=20, total=400, + ingredient='minecraft:nether_wart')) + out.append(tick_case(401, [lingering, None, None, wart, None], fuel=20, total=400, + ingredient='minecraft:nether_wart')) + # Twenty brews from one blaze powder's twenty uses; the 21st ingredient stays. + out.append(tick_case(8001, [water, None, None, wart21, blaze], total=0, + ingredient='minecraft:nether_wart')) + out.append(tick_case(8002, [water, None, None, wart21, blaze], total=0, + ingredient='minecraft:nether_wart')) + # Bottle bits are set from the slots on the first tick. + for slots in ([None, None, None], [water, None, None], [water, None, water], + [water, awkward, water]): + out.append(tick_case(1, slots + [None, None], fuel=20)) + return out + + +def make_cases(build): + resources = lab.LOCAL / 'horizon-client' / '26.3' + catalog = json.loads((resources / 'items.json').read_text()) + all_recipes = recipes(resources) + potions = sorted({p for r in all_recipes + for p in (r['output'].get('components', {}) + .get('minecraft:potion_contents', {}).get('potion', ''),) + if p}) + potion_ids = sorted({r['input'].split('"potion":"')[1].split('"')[0] + for r in all_recipes if '"potion":"' in r['input']}) + reagents = sorted({r['reagent'] for r in all_recipes}) + cases = [] + # The baseline corpus: each bundled recipe's exact pair with each of the + # three slots probed. --baseline reproduces it without the cross-product. + for r in all_recipes: + for candidate in (r['input'], r['reagent'], stack('minecraft:blaze_powder'), + stack('minecraft:stick')): + cases.append(f"{r['input']} {r['reagent']} {candidate}") + if '--baseline' not in sys.argv: + # Every potion item and potion id against every reagent, plus a few + # mismatches and empty sides. + for item in POTION_ITEMS: + for potion in potion_ids + ['minecraft:water', 'minecraft:awkward']: + for reagent in reagents: + inp = stack('minecraft:' + item, potion) + cases.append(f"{inp} {reagent} {inp}") + cases.append(f"{stack('minecraft:' + item, potion)} {stack('minecraft:stick')} -") + cases.append(f"- {stack('minecraft:stick')} {stack('minecraft:' + item, potion)}") + # Every item as a filter candidate, so the slot rules are compared on + # the whole registry and not only on the recipe inputs. + for item in sorted(catalog): + if item != 'minecraft:air': + cases.append(f"- - {stack(item)}") + for potion in potion_ids: + for item in POTION_ITEMS: + cases.append(f"- - {stack('minecraft:' + item, potion)}") + if '--no-tick' not in sys.argv: + cases += tick_cases() + # Duplicate-free while keeping the first occurrence order. + seen = set() + unique = [] + for case in cases: + if case not in seen: + seen.add(case) + unique.append(case) + path = build / 'cases.txt' + path.write_text(f'#resources {resources}\n' + ''.join(c + '\n' for c in unique)) + return path, len(unique) + + +if __name__ == '__main__': + harness.differential( + 'brewing-parity', 'VanillaBrewingOracle', 'brewing_fixture', make_cases, + 'BrewingRecipe matching and assemble over the vanilla brewing data, the brewing_input and ' + 'brewing_reagent property sets and the potion, reagent and fuel slot filters, for every ' + 'bundled recipe, potion type/id and item candidate, plus the brewing-stand tick: brew time and ' + 'speed multiplier, fuel uses and loading, ingredient shrink, outputs and bottle bits. --baseline ' + 'leaves out the cross-product and whole-registry slot candidates; --no-tick leaves out the tick cases.', + sources=[lab.ROOT / 'crates/horizon-client-render/src/brewing.rs', + lab.ROOT / 'crates/horizon-items/src/item_data.rs', + lab.ROOT / 'crates/horizon-items/src/items.rs'])