test: brewing stand tick, brew time and fuel uses

The brewing suite stopped at recipe matching, assemble and the slot filters;
nothing advanced the block entity. The tick family now runs the real
BrewingStandBlockEntity.serverTick and BrewingStand::tick for 0..8001 ticks
and compares slots, brew time, total brew time, fuel, total fuel, speed,
bottle bits, ingredient and the brew-event count. It covers the 400-tick brew
divided by speed_multiplier and its ceiling (2, 3, 0.3 and 0), blaze powder's
twenty uses and speed 1, fuel loaded with nothing to brew, splash and
lingering outputs, ingredient shrink and mismatch, finishing without fuel and
twenty brews from one blaze powder. --no-tick leaves the tick corpus out.

2860/2860 cases match the pinned client.
This commit is contained in:
MSedgeMC committed 2026-11-06 21:54:20 +00:00
1 parent bb24c87d5d
commit 73737f9e6c
3 files changed
+234 -11

No files matched your search

@@ -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<ItemStack>; 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::<str>::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])?;
@@ -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<RecipeHolder<BrewingRecipe>> brewing = recipes.getRecipes().stream()
.filter(h -> h.value() instanceof BrewingRecipe)
.map(h -> (RecipeHolder<BrewingRecipe>) 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();
}
}
+92 -7
View File
@@ -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'])