diff --git a/crates/horizon-client/examples/probe/brewing_fixture.rs b/crates/horizon-client/examples/probe/brewing_fixture.rs index d1fa7a6e96..58dec3dbf4 100644 --- a/crates/horizon-client/examples/probe/brewing_fixture.rs +++ b/crates/horizon-client/examples/probe/brewing_fixture.rs @@ -1,14 +1,18 @@ //! 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. +//! 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, brewing_fuel}, + brewing::{Brewing, BrewingStand, brewing_fuel}, item_data::{canon_stack, parse_stack}, - items::Items, + 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<()> { @@ -21,6 +25,7 @@ pub fn main() -> horizon_client::Result<()> { .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() @@ -28,6 +33,45 @@ pub fn main() -> horizon_client::Result<()> { .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])?; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java index 2d2da5a7bd..d95d3e0173 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBrewingOracle.java @@ -1,13 +1,25 @@ 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; @@ -20,14 +32,24 @@ 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". + * `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) @@ -41,6 +63,11 @@ public final class VanillaBrewingOracle { 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]); @@ -59,4 +86,71 @@ public final class VanillaBrewingOracle { } 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 index ae7a6eab2e..56886b54ad 100644 --- a/tools/test_brewing_parity.py +++ b/tools/test_brewing_parity.py @@ -7,7 +7,13 @@ 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. +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 @@ -21,11 +27,11 @@ except ImportError: POTION_ITEMS = ['potion', 'splash_potion', 'lingering_potion'] -def stack(item, potion=None): +def stack(item, potion=None, count=1): if potion: - return '{id:"%s",count:1,components:{"minecraft:potion_contents":{potion:"%s"}}}' % ( - item, potion) - return '{id:"%s",count:1}' % item + return '{id:"%s",count:%d,components:{"minecraft:potion_contents":{potion:"%s"}}}' % ( + item, count, potion) + return '{id:"%s",count:%d}' % (item, count) def recipes(resources): @@ -43,6 +49,82 @@ def recipes(resources): 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()) @@ -79,6 +161,8 @@ def make_cases(build): 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 = [] @@ -96,8 +180,9 @@ if __name__ == '__main__': '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. --baseline leaves out the cross-product ' - 'and whole-registry slot candidates. The brewing-stand tick, brew time and fuel use remain pending.', + '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'])