mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 17:19:31 -10:00
Merge pull request #12649 from TheMrZZ/fix/recipe-book-display-variants
🤖 Agent: transmute recipes show one recipe book entry per display, pinned by the new recipe_book_variants harness.
This commit is contained in:
6 files changed
+566
-10
No files matched your search
@@ -1350,6 +1350,12 @@ pub struct Recipes {
|
||||
}
|
||||
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, Vec<usize>>>;
|
||||
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
|
||||
/// `TransmuteRecipe`'s `material_count` as the recipe parser reads it: a
|
||||
/// number is exact, an object's missing ends are 1 and 8, both kept in 1..8.
|
||||
pub(crate) fn transmute_material_bounds(material_count: Option<&Value>) -> (usize, usize) {
|
||||
let (min, max) = bounds(material_count, (1, 1), (1, 8));
|
||||
(min.clamp(1, 8) as usize, max.clamp(1, 8) as usize)
|
||||
}
|
||||
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
|
||||
match v {
|
||||
Some(Value::Number(n)) => {
|
||||
@@ -1634,12 +1640,12 @@ impl Recipes {
|
||||
}),
|
||||
),
|
||||
"minecraft:crafting_transmute" => special((|| {
|
||||
let (min, max) = bounds(v.get("material_count"), (1, 1), (1, 8));
|
||||
let (min, max) = transmute_material_bounds(v.get("material_count"));
|
||||
Some(Special::Transmute {
|
||||
input: ing("input")?,
|
||||
material: ing("material")?,
|
||||
min: min.clamp(1, 8) as usize,
|
||||
max: max.clamp(1, 8) as usize,
|
||||
min,
|
||||
max,
|
||||
add_count: v["add_material_count_to_result"].as_bool().unwrap_or(false),
|
||||
result: result()?,
|
||||
})
|
||||
|
||||
@@ -19157,6 +19157,8 @@ mod orb_data_tests;
|
||||
#[cfg(test)]
|
||||
mod orb_score_store_tests;
|
||||
#[cfg(test)]
|
||||
mod recipe_book_variants_tests;
|
||||
#[cfg(test)]
|
||||
mod stand_break_loot_tests;
|
||||
#[cfg(test)]
|
||||
mod stand_command_hurt_tests;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! A recipe with several displays reaches a connected player's recipe book:
|
||||
//! `ServerRecipeBook.addRecipes` sends a `ClientboundRecipeBookAddPacket.Entry`
|
||||
//! for each display (`RecipeManager.listDisplaysForRecipe`), so
|
||||
//! `minecraft:map_cloning`, a transmute recipe with a material count of 1 to 8,
|
||||
//! sends eight (tools/test_recipe_book_variants_parity.py measures the same on
|
||||
//! vanilla 26.3 with a real client).
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig},
|
||||
motion::PlayerMotion,
|
||||
};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
const GUEST: &str = "RecipeGuest";
|
||||
|
||||
/// A host on a stone floor with one connected, placed guest (conn 2).
|
||||
fn game() -> (Game<'static>, mpsc::Receiver<horizon_client::lan::Out>) {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for x in 0..2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
|
||||
}
|
||||
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
|
||||
let net = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "recipe-variants-test".into(), 2)
|
||||
};
|
||||
game.lan_host(
|
||||
super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
net,
|
||||
HubConfig {
|
||||
default_game_mode: 1,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: Dim::Overworld.id().into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: GUEST.into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[12.5, 100.0, 10.5],
|
||||
1,
|
||||
);
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
// Its view, as the client sent it: straight down.
|
||||
guest.pitch = 90.0;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
(game, rx)
|
||||
}
|
||||
|
||||
/// Runs `command`: whether it succeeded and what the issuer was told.
|
||||
fn run(game: &mut Game<'_>, command: &str) -> (bool, Vec<String>) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
let ok = game.run_command(command);
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
(
|
||||
ok,
|
||||
said.iter().map(horizon_client::text::Text::plain).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The display ids of every recipe book Add the guest was sent.
|
||||
fn added(rx: &mpsc::Receiver<horizon_client::lan::Out>) -> Vec<Vec<i32>> {
|
||||
use horizon_protocol::packets::play::Clientbound as C;
|
||||
let mut result = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
let bodies = match out {
|
||||
horizon_client::lan::Out::Packet(body) => vec![body],
|
||||
horizon_client::lan::Out::Packets(bodies) => bodies,
|
||||
horizon_client::lan::Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
};
|
||||
for body in bodies {
|
||||
let mut reader = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
if let Ok(C::RecipeBookAdd(add)) = C::decode(id, reader.rest()) {
|
||||
result.push(add.entries.iter().map(|e| e.id).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn step(game: &mut Game<'_>, ticks: usize) {
|
||||
for _ in 0..ticks {
|
||||
assert!(game.run_command("tick step 1"));
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn grant(
|
||||
game: &mut Game<'_>,
|
||||
rx: &mpsc::Receiver<horizon_client::lan::Out>,
|
||||
recipe: &str,
|
||||
) -> Vec<Vec<i32>> {
|
||||
let _ = added(rx);
|
||||
let (ok, said) = run(game, &format!("recipe give {GUEST} {recipe}"));
|
||||
assert!(ok, "{said:?}");
|
||||
// The book's packets go out with a later tick's sync (measured: the next
|
||||
// one but one); two ticks take them all.
|
||||
step(game, 2);
|
||||
added(rx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_recipe_with_eight_displays_sends_eight_entries() {
|
||||
let (mut game, rx) = game();
|
||||
assert!(game.run_command("tick freeze"));
|
||||
// The join's own book (a new player knows the crafting table) settles on
|
||||
// these ticks; then the book is emptied.
|
||||
step(&mut game, 3);
|
||||
let _ = run(&mut game, &format!("recipe take {GUEST} *"));
|
||||
step(&mut game, 2);
|
||||
let _ = added(&rx);
|
||||
let ordinary = grant(&mut game, &rx, "minecraft:crafting_table");
|
||||
assert_eq!(
|
||||
ordinary.iter().map(Vec::len).sum::<usize>(),
|
||||
1,
|
||||
"the ordinary recipe: {ordinary:?}"
|
||||
);
|
||||
let cloning = grant(&mut game, &rx, "minecraft:map_cloning");
|
||||
let ids: Vec<i32> = cloning.concat();
|
||||
assert_eq!(ids.len(), 8, "one entry per display: {cloning:?}");
|
||||
let mut distinct = ids.clone();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
assert_eq!(distinct.len(), 8);
|
||||
}
|
||||
|
||||
// Added after the baseline (the authorized adapter): a recipe with several
|
||||
// displays still has no ghost (PlaceRecipe is unchanged), and an ordinary
|
||||
// recipe's ghost is still sent.
|
||||
fn ghosts(rx: &mpsc::Receiver<horizon_client::lan::Out>) -> usize {
|
||||
use horizon_protocol::packets::play::Clientbound as C;
|
||||
let mut n = 0;
|
||||
for out in rx.try_iter() {
|
||||
let bodies = match out {
|
||||
horizon_client::lan::Out::Packet(body) => vec![body],
|
||||
horizon_client::lan::Out::Packets(bodies) => bodies,
|
||||
horizon_client::lan::Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
};
|
||||
for body in bodies {
|
||||
let mut reader = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
if let Ok(C::PlaceGhostRecipe(_)) = C::decode(id, reader.rest()) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn control_a_multi_display_recipe_still_has_no_ghost() {
|
||||
let (mut game, rx) = game();
|
||||
let _ = ghosts(&rx);
|
||||
let hub = &mut game.lan.as_mut().unwrap().hub;
|
||||
hub.send_ghost_recipe(2, 0, "minecraft:map_cloning");
|
||||
assert_eq!(ghosts(&rx), 0, "no ghost for eight displays");
|
||||
let hub = &mut game.lan.as_mut().unwrap().hub;
|
||||
hub.send_ghost_recipe(2, 0, "minecraft:crafting_table");
|
||||
assert_eq!(ghosts(&rx), 1, "the ordinary recipe's ghost");
|
||||
}
|
||||
@@ -379,6 +379,11 @@ impl ServerAdvancements {
|
||||
id: &str,
|
||||
sync: &SyncData,
|
||||
) -> Option<horizon_protocol::packets::recipes::RecipeDisplay> {
|
||||
// A recipe with several displays has no ghost here (PlaceRecipe is
|
||||
// not this change's): only a single-display recipe's entry is used.
|
||||
if self.displays.display_ids(id).len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.book_entries(id, sync, 0).into_iter().next()?;
|
||||
let mut r = horizon_protocol::buf::Reader::new(&bytes);
|
||||
horizon_protocol::packets::recipes::RecipeBookEntry::read(&mut r)
|
||||
@@ -390,10 +395,6 @@ impl ServerAdvancements {
|
||||
/// highlighted, and with a notification unless the recipe turns it off.
|
||||
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
|
||||
let ids = self.displays.display_ids(id);
|
||||
if ids.len() != 1 {
|
||||
// Special recipes have no display; transmutations aren't unlocked on joining.
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(r) = self
|
||||
.recipes
|
||||
.get(id)
|
||||
@@ -402,6 +403,15 @@ impl ServerAdvancements {
|
||||
return Vec::new();
|
||||
};
|
||||
let r = &r;
|
||||
if r["type"].as_str() == Some("minecraft:crafting_transmute") {
|
||||
return self
|
||||
.transmute_entries(r, ids, sync, flags)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if ids.len() != 1 {
|
||||
// Special recipes have no display.
|
||||
return Vec::new();
|
||||
}
|
||||
let kind = r["type"].as_str().unwrap_or("");
|
||||
let item = |n: &str| sync.id("minecraft:item", n);
|
||||
let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0);
|
||||
@@ -496,7 +506,22 @@ impl ServerAdvancements {
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
// The group, the recipe book category and the placement's ingredients.
|
||||
self.entry_tail(&mut w, r, sync, category, &placed, flags);
|
||||
vec![w.data]
|
||||
}
|
||||
|
||||
/// An entry's end: the group, the recipe book category, the placement's
|
||||
/// ingredients and the flags.
|
||||
fn entry_tail(
|
||||
&self,
|
||||
w: &mut Writer,
|
||||
r: &Value,
|
||||
sync: &SyncData,
|
||||
category: &str,
|
||||
placed: &[&Value],
|
||||
flags: i8,
|
||||
) {
|
||||
let item = |n: &str| sync.id("minecraft:item", n);
|
||||
match r["group"].as_str().and_then(|g| self.groups.get(g)) {
|
||||
Some(g) => w.varint(g + 1),
|
||||
None => w.varint(0),
|
||||
@@ -509,12 +534,113 @@ impl ServerAdvancements {
|
||||
w.bool(true);
|
||||
w.varint(placed.len() as i32);
|
||||
for v in placed {
|
||||
holder_set(&mut w, v, &item);
|
||||
holder_set(w, v, &item);
|
||||
}
|
||||
// The notification only where the recipe shows one.
|
||||
let notify = r["show_notification"].as_bool().unwrap_or(true);
|
||||
w.i8(if notify { flags } else { flags & !1 });
|
||||
vec![w.data]
|
||||
}
|
||||
|
||||
/// `TransmuteRecipe.display`: one `ShapelessCraftingRecipeDisplay` per
|
||||
/// material count from the recipe's minimum to its maximum, the input
|
||||
/// then that many materials, its `resultDisplay`, the crafting table; the
|
||||
/// placement (`placementInfo`) is the input and the most materials. All
|
||||
/// or nothing: a form this cannot write, or a count that is not the
|
||||
/// recipe's display ids, gives no entry at all.
|
||||
fn transmute_entries(
|
||||
&self,
|
||||
r: &Value,
|
||||
ids: std::ops::Range<i32>,
|
||||
sync: &SyncData,
|
||||
flags: i8,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let (min, max) = crate::crafting::transmute_material_bounds(r.get("material_count"));
|
||||
if (max + 1).checked_sub(min)? != ids.len() {
|
||||
return None;
|
||||
}
|
||||
let item = |n: &str| sync.id("minecraft:item", n);
|
||||
let slot = |n: &str| sync.id("minecraft:slot_display", n);
|
||||
let (input, material) = (&r["input"], &r["material"]);
|
||||
let holder = |v: &Value| matches!(v, Value::String(_));
|
||||
if !holder(input) || !holder(material) {
|
||||
return None;
|
||||
}
|
||||
// TransmuteResult: an item (or none: the input's own items), a count
|
||||
// (1) and components, which this does not write.
|
||||
let result = &r["result"];
|
||||
if result
|
||||
.get("components")
|
||||
.is_some_and(|c| !c.as_object().is_some_and(|o| o.is_empty()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = result["count"].as_i64().unwrap_or(1) as i32;
|
||||
let add = r["add_material_count_to_result"].as_bool().unwrap_or(false);
|
||||
let result_items: Vec<i32> = match result["id"].as_str() {
|
||||
Some(id) => vec![item(id)?],
|
||||
// Composite over the input's items, in the order clients get the tag.
|
||||
None => match input.as_str()? {
|
||||
tag if tag.starts_with('#') => {
|
||||
let name = &tag[1..];
|
||||
sync.tags
|
||||
.registries
|
||||
.iter()
|
||||
.find(|t| t.registry == "minecraft:item")?
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.name == name)?
|
||||
.ids
|
||||
.clone()
|
||||
}
|
||||
one => vec![item(one)?],
|
||||
},
|
||||
};
|
||||
let (shapeless, tag, empty_stack) = (
|
||||
sync.id("minecraft:recipe_display", "minecraft:crafting_shapeless")?,
|
||||
slot("minecraft:tag")?,
|
||||
slot("minecraft:item_stack")?,
|
||||
);
|
||||
let (composite, item_slot) = (slot("minecraft:composite")?, slot("minecraft:item")?);
|
||||
let table = item("minecraft:crafting_table")?;
|
||||
let category = crafting_category(r["category"].as_str().unwrap_or("")).name();
|
||||
let placed: Vec<&Value> = std::iter::once(input)
|
||||
.chain(std::iter::repeat_n(material, max))
|
||||
.collect();
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
for (n, id) in (min..=max).zip(ids) {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
w.varint(shapeless);
|
||||
w.varint(n as i32 + 1);
|
||||
for v in std::iter::once(input).chain(std::iter::repeat_n(material, n)) {
|
||||
w.varint(tag);
|
||||
holder_set(&mut w, v, &item);
|
||||
}
|
||||
// computeResultSize: the materials added to the count, or not.
|
||||
let count = if add { n as i32 + base } else { base };
|
||||
let stack = |w: &mut Writer, id: i32| {
|
||||
w.varint(empty_stack);
|
||||
w.varint(id);
|
||||
w.varint(count);
|
||||
w.varint(0);
|
||||
w.varint(0);
|
||||
};
|
||||
match result["id"].as_str() {
|
||||
Some(_) => stack(&mut w, result_items[0]),
|
||||
None => {
|
||||
w.varint(composite);
|
||||
w.varint(result_items.len() as i32);
|
||||
for &id in &result_items {
|
||||
stack(&mut w, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
w.varint(item_slot);
|
||||
w.varint(table);
|
||||
self.entry_tail(&mut w, r, sync, category, &placed, flags);
|
||||
entries.push(w.data);
|
||||
}
|
||||
Some(entries)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -710,9 +710,21 @@ public final class LocatorBarClient {
|
||||
value.addProperty("flags", entry.flags());
|
||||
value.add("display", net.minecraft.world.item.crafting.display.RecipeDisplay.CODEC.encodeStart(
|
||||
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), entry.contents().display()).getOrThrow());
|
||||
var contents = entry.contents();
|
||||
if (contents.group().isPresent()) value.addProperty("group", contents.group().getAsInt());
|
||||
value.addProperty("category", net.minecraft.core.registries.BuiltInRegistries.RECIPE_BOOK_CATEGORY
|
||||
.getKey(contents.category()).toString());
|
||||
contents.craftingRequirements().ifPresent(list -> value.add("requirements",
|
||||
net.minecraft.world.item.crafting.Ingredient.CODEC.listOf().encodeStart(
|
||||
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), list).getOrThrow()));
|
||||
entries.add(value);
|
||||
}
|
||||
j.add("entries", entries);
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundRecipeBookRemovePacket c) {
|
||||
j.addProperty("kind", "recipes-removed");
|
||||
var ids = new com.google.gson.JsonArray();
|
||||
for (var id : c.recipes()) ids.add(id.index());
|
||||
j.add("ids", ids);
|
||||
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundPlaceGhostRecipePacket c) {
|
||||
j.addProperty("kind", "ghost");
|
||||
j.addProperty("window", c.containerId());
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A multi-display recipe reaches a real client's recipe book (server-recipe-book-display-variants).
|
||||
|
||||
`ServerRecipeBook.addRecipes` sends one `ClientboundRecipeBookAddPacket.Entry` per display a recipe has
|
||||
(`RecipeManager.listDisplaysForRecipe`), and `removeRecipes` a `ClientboundRecipeBookRemovePacket` of all of
|
||||
them. `minecraft:map_cloning` is a `crafting_transmute` recipe whose `material_count` runs from 1 to 8:
|
||||
`TransmuteRecipe.display()` gives it eight shapeless displays.
|
||||
|
||||
A real network player (LocatorBarClient) joins a dedicated server (vanilla, then Horizon) with the
|
||||
menu recorder on: every recipe book Add and Remove packet is kept with its raw payload and its fields as
|
||||
vanilla's own codec decodes them (the client refuses a payload with bytes left over). With the recipe book
|
||||
emptied, the player is given `minecraft:crafting_table` (an ordinary shaped recipe, the positive), then
|
||||
`minecraft:map_cloning`, then both are taken back, map cloning first. After each step the player's
|
||||
`recipeBook` is read through command storage.
|
||||
|
||||
Primary slice, declared before any fixed capture: `target-` keys are the map cloning grant's Add entries
|
||||
and its Remove ids. `diag-` keys are recorded diagnostics (the join packets, absolute ids). Every other key is
|
||||
a setup admission or a control.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
import anvil_dump as nbt
|
||||
import test_locator_bar_parity as net
|
||||
from test_disconnect_crafting_parity import generate, wait_new
|
||||
|
||||
PLAYER = 'Bob'
|
||||
STEPS = [
|
||||
('take-all', 'recipe take Bob *'),
|
||||
('give-ordinary', 'recipe give Bob minecraft:crafting_table'),
|
||||
('give-transmute', 'recipe give Bob minecraft:map_cloning'),
|
||||
('take-transmute', 'recipe take Bob minecraft:map_cloning'),
|
||||
('take-ordinary', 'recipe take Bob minecraft:crafting_table'),
|
||||
]
|
||||
TARGET = {'give-transmute', 'take-transmute'}
|
||||
|
||||
|
||||
def run(label, command, client_command, build, world):
|
||||
directory = build / label
|
||||
port = net.server_dir(directory, world)
|
||||
server = net.Server(command, directory, label)
|
||||
wire = build / f'{label}-wire.txt'
|
||||
log = (build / f'{label}-client.log').open('w')
|
||||
client = None
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
client = net.Client(client_command, port, wire, log)
|
||||
client.tell('chat on')
|
||||
client.tell('flat-chat on')
|
||||
client.tell('menus on')
|
||||
client.tell('step join')
|
||||
client.tell(f'join {PLAYER}')
|
||||
client.sync([PLAYER])
|
||||
|
||||
def cmd(step, text):
|
||||
client.tell('step ' + step)
|
||||
client.tell(f'send-cmd {PLAYER} {text}')
|
||||
client.sync([PLAYER])
|
||||
|
||||
cmd('admit', 'gamemode survival Bob')
|
||||
cmd('admit', 'data modify storage recipes:proof player set from entity Bob')
|
||||
for step, text in STEPS:
|
||||
cmd(step, text)
|
||||
cmd(step + '-book', f'data modify storage recipes:proof {step.replace("-", "_")} set from entity Bob recipeBook')
|
||||
client.tell('step save')
|
||||
client.sync([PLAYER])
|
||||
start = len(server.lines)
|
||||
server.console('save-all flush')
|
||||
wait_new(server, start, 'Saved the game')
|
||||
client.end()
|
||||
finally:
|
||||
if client:
|
||||
client.kill()
|
||||
server.stop()
|
||||
log.close()
|
||||
storage = [p for p in (directory / 'world').rglob('*.dat')
|
||||
if 'command_storage' in p.name and ('recipes' in p.parts or 'recipes' in p.name)]
|
||||
assert len(storage) == 1, storage
|
||||
proof = json.loads(json.dumps(nbt.read_dat(storage[0])))['data']['contents']['proof']
|
||||
steps = {}
|
||||
for line in wire.read_text().splitlines():
|
||||
if not line.startswith(PLAYER + ' step='):
|
||||
continue
|
||||
prefix, rest = line.split(' step=', 1)
|
||||
step, body = rest.split(' ', 1)
|
||||
record = steps.setdefault(step, {'packets': [], 'chat': []})
|
||||
if body.startswith('menu-wire '):
|
||||
p = json.loads(body[len('menu-wire '):])
|
||||
if p.get('kind') in ('recipes', 'recipes-removed'):
|
||||
record['packets'].append(p)
|
||||
elif body.startswith('chat-flat '):
|
||||
record['chat'].append(json.loads(body[len('chat-flat '):]))
|
||||
return proof, steps
|
||||
|
||||
|
||||
def facts(proof, steps):
|
||||
out = {}
|
||||
player = proof['player']
|
||||
for key in ('UUID', 'Health', 'playerGameType'):
|
||||
out[f'admit/player/{key}'] = player.get(key)
|
||||
out['admit/initial-book/recipes'] = sorted(player.get('recipeBook', {}).get('recipes', []))
|
||||
out['diag-join/packets'] = steps.get('join', {}).get('packets')
|
||||
for step, _ in STEPS:
|
||||
record = steps.get(step, {'packets': [], 'chat': []})
|
||||
# An action's packets: its own step and its book read after it (a
|
||||
# server may send the book's packets after the command's reply).
|
||||
later = steps.get(step + '-book', {'packets': []})['packets']
|
||||
packets = record['packets'] + later
|
||||
prefix = 'target-' if step in TARGET else ''
|
||||
out[f'{step}/chat'] = record['chat']
|
||||
adds = [p for p in packets if p['kind'] == 'recipes']
|
||||
removes = [p for p in packets if p['kind'] == 'recipes-removed']
|
||||
out[f'diag-{step}/after-reply'] = [p['kind'] for p in later]
|
||||
out[f'{prefix}{step}/add/count'] = [len(p['entries']) for p in adds]
|
||||
out[f'{prefix}{step}/add/replace'] = [p['replace'] for p in adds]
|
||||
out[f'{prefix}{step}/add/entries'] = [
|
||||
{k: v for k, v in e.items() if k != 'id'} for p in adds for e in p['entries']]
|
||||
out[f'{prefix}{step}/add/ids'] = [e['id'] for p in adds for e in p['entries']]
|
||||
out[f'{prefix}{step}/remove/ids'] = [i for p in removes for i in p['ids']]
|
||||
out[f'diag-{step}/raw'] = [p['wire'] for p in packets]
|
||||
book = proof.get(step.replace('-', '_'), {})
|
||||
# ServerRecipeBook keeps sets: their saved order is not the book's.
|
||||
out[f'{step}/book/recipes'] = sorted(book.get('recipes', []))
|
||||
out[f'{step}/book/toBeDisplayed'] = sorted(book.get('toBeDisplayed', []))
|
||||
# The ids the transmute grant added are the ids its removal takes back.
|
||||
give = out['target-give-transmute/add/ids']
|
||||
out['target-take-transmute/remove-matches-add'] = sorted(give) == sorted(out['target-take-transmute/remove/ids'])
|
||||
return out
|
||||
|
||||
|
||||
def recipe_bytes(jar, resources):
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
java = archive.read('data/minecraft/recipe/map_cloning.json')
|
||||
native = (resources / 'data/minecraft/recipe/map_cloning.json').read_bytes()
|
||||
return {'java': hashlib.sha256(java).hexdigest(), 'native': hashlib.sha256(native).hexdigest()}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--side', choices=['java', 'native', 'both'], default='both')
|
||||
parser.add_argument('--label')
|
||||
parser.add_argument('--binary', type=Path)
|
||||
parser.add_argument('--reference', type=Path, help='a Java facts file to compare with')
|
||||
args = parser.parse_args()
|
||||
net.PLAYERS = [PLAYER]
|
||||
net.PROPERTIES['force-gamemode'] = 'false'
|
||||
lock, build, java, native, client = net.sides('recipe-book-variants-parity',
|
||||
args.binary.resolve() if args.binary else None)
|
||||
import test_rust_parity as base
|
||||
_, jars, _ = base.verify_artifacts()
|
||||
admitted = recipe_bytes(jars['server'], net.lab.LOCAL / 'horizon-client/26.3')
|
||||
(build / 'recipe-bytes.json').write_text(json.dumps(admitted, indent=2) + '\n')
|
||||
assert admitted['java'] == admitted['native'], admitted
|
||||
world = build / 'generate/world'
|
||||
if not world.exists():
|
||||
world = generate(java, build)
|
||||
sides = ['java', 'native'] if args.side == 'both' else [args.side]
|
||||
reference = json.loads(args.reference.read_text()) if args.reference else None
|
||||
for side in sides:
|
||||
label = args.label or side
|
||||
proof, steps = run(label, java if side == 'java' else native, client, build, world)
|
||||
actual = facts(proof, steps)
|
||||
(build / f'{label}-facts.json').write_text(json.dumps(actual, indent=2, sort_keys=True) + '\n')
|
||||
if reference is None:
|
||||
reference = actual
|
||||
print(label, len(actual), 'facts captured')
|
||||
continue
|
||||
assert actual.keys() == reference.keys(), 'canonical keys changed'
|
||||
all_differences = [{'label': k, 'expected': reference[k], 'actual': actual[k]}
|
||||
for k in reference if reference[k] != actual[k]]
|
||||
differences = [d for d in all_differences if not d['label'].startswith('diag-')]
|
||||
strict = [k for k in reference if not k.startswith('diag-')]
|
||||
report = {'records': len(strict), 'passed': len(strict) - len(differences), 'differences': differences,
|
||||
'diagnostic_differences': [d['label'] for d in all_differences if d['label'].startswith('diag-')]}
|
||||
(build / f'{label}-report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(label, report['passed'], '/', report['records'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user