mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Ctrl + Pick Block copies the block entity's data in creative
ServerGamePacketListenerImpl.addBlockDataToItem: with Ctrl held in creative, Pick Block gives the block's item the block entity's data. What each type turns into item components (removeComponentsFromTag and collectImplicitComponents: container, custom_name, lock, container_loot, sign text, waxed, bees, banner patterns, pot decorations, skull profile) leaves its NBT; the rest becomes minecraft:block_entity_data, tagged with the type's id. Placing the item does the reverse (BlockItem.updateCustomBlockEntityTag, op-only types only for a game master, then applyComponentsFromItemStack), for every block entity type, which also fills placed chests, barrels, hoppers and the like from their container component, as vanilla does, where only shulker boxes were. The client sends includeData on Ctrl in multiplayer, a picked banner keeps its patterns without Ctrl (AbstractBannerBlock.getCloneItemStack), a picked beehive carries its honey level, a placed command block with data keeps its own auto and output tracking, and a placed waxed sign opens no editor. tools/test_pick_block_data_parity.py compares both directions with vanilla's own block entities: 148/148 cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
90644915c5
commit
bd42496af0
13 files changed
+925
-73
No files matched your search
@@ -0,0 +1,96 @@
|
||||
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
|
||||
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
|
||||
//! picked item's component patch, a tab, then the placed block entity.
|
||||
//!
|
||||
//! usage: pick_block_data_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::{pick, place};
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::java_text::{double_to_string, float_to_string};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
|
||||
out.push_str(prefix);
|
||||
out.push_str(&items.join(","));
|
||||
out.push(']');
|
||||
};
|
||||
match tag {
|
||||
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
|
||||
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
|
||||
Tag::List(items) => join(
|
||||
out,
|
||||
"[",
|
||||
items
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = 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(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
let picked = pick(&saved);
|
||||
let patch = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(c) => c,
|
||||
_ => return None,
|
||||
},
|
||||
None => picked.clone(),
|
||||
};
|
||||
let target = canonical(&snbt(&case["target"])?)?;
|
||||
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
|
||||
return None;
|
||||
};
|
||||
// keepPacked is the chunk's, not saveWithFullMetadata's.
|
||||
placed.remove(&"keepPacked".into());
|
||||
let mut out = String::new();
|
||||
canon(&Tag::Compound(picked), &mut out);
|
||||
out.push('\t');
|
||||
canon(&Tag::Compound(placed), &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
|
||||
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
|
||||
//! (`BlockItem.updateCustomBlockEntityTag`, then
|
||||
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
|
||||
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
|
||||
//!
|
||||
//! What a type turns into item components (`removeComponentsFromTag`,
|
||||
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
|
||||
//! `minecraft:block_entity_data`, tagged with the type's id.
|
||||
use crate::block_entity_nbt::{self, canonical};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
|
||||
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
|
||||
|
||||
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
|
||||
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
|
||||
pub const OP_ONLY: [&str; 6] = [
|
||||
"minecraft:command_block",
|
||||
"minecraft:lectern",
|
||||
"minecraft:sign",
|
||||
"minecraft:hanging_sign",
|
||||
"minecraft:mob_spawner",
|
||||
"minecraft:trial_spawner",
|
||||
];
|
||||
|
||||
/// A part of a block entity's NBT that its item holds as a component.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Part {
|
||||
/// The NBT key and the component hold the same value.
|
||||
Same(&'static str, &'static str),
|
||||
/// `Items` as `minecraft:container`, in a container of this many slots.
|
||||
Items(usize),
|
||||
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
|
||||
Loot,
|
||||
/// `is_waxed` as `minecraft:waxed`.
|
||||
Waxed,
|
||||
/// A decorated pot's `item` as `minecraft:container`.
|
||||
PotItem,
|
||||
/// Left out of the item's data, with no component.
|
||||
Drop(&'static str),
|
||||
}
|
||||
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
|
||||
const LOCK: Part = Part::Same("lock", "minecraft:lock");
|
||||
const CONTAINER: &str = "minecraft:container";
|
||||
|
||||
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
|
||||
/// `applyImplicitComponents`.
|
||||
fn parts(id: &str) -> &'static [Part] {
|
||||
match id {
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
|
||||
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
|
||||
&[NAME, LOCK, Part::Items(9), Part::Loot]
|
||||
}
|
||||
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
|
||||
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
|
||||
&[NAME, LOCK, Part::Items(3)]
|
||||
}
|
||||
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
|
||||
"minecraft:beacon" => &[NAME, LOCK],
|
||||
"minecraft:enchanting_table" => &[NAME],
|
||||
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
|
||||
"minecraft:campfire" => &[Part::Items(4)],
|
||||
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
|
||||
"minecraft:shelf" => &[Part::Items(3)],
|
||||
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
|
||||
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
|
||||
"minecraft:decorated_pot" => &[
|
||||
Part::Same("sherds", "minecraft:pot_decorations"),
|
||||
Part::PotItem,
|
||||
],
|
||||
"minecraft:sign" | "minecraft:hanging_sign" => &[
|
||||
Part::Same("front_text", "minecraft:sign_text_front"),
|
||||
Part::Same("back_text", "minecraft:sign_text_back"),
|
||||
Part::Waxed,
|
||||
],
|
||||
"minecraft:skull" => &[
|
||||
Part::Same("profile", "minecraft:profile"),
|
||||
Part::Same("note_block_sound", "minecraft:note_block_sound"),
|
||||
Part::Same("custom_name", "minecraft:custom_name"),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a part reads, and what it holds for `tag` (None: unset).
|
||||
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
|
||||
Some(match part {
|
||||
Part::Same(key, component) => (
|
||||
component,
|
||||
tag.get(key).cloned().or_else(|| unset(component)),
|
||||
),
|
||||
Part::Items(_) => {
|
||||
let slots = match tag.get("Items") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots)))
|
||||
}
|
||||
Part::Loot => {
|
||||
let table = tag.get("LootTable").cloned();
|
||||
let loot = table.map(|t| {
|
||||
let mut entries = vec![("loot_table", t)];
|
||||
if let Some(seed) = tag.get("LootTableSeed") {
|
||||
entries.push(("seed", seed.clone()));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
});
|
||||
("minecraft:container_loot", loot)
|
||||
}
|
||||
Part::Waxed => (
|
||||
"minecraft:waxed",
|
||||
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
|
||||
.then(|| Tag::Compound(Compound::new())),
|
||||
),
|
||||
Part::PotItem => {
|
||||
let slots = match tag.get("item") {
|
||||
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
|
||||
_ => None,
|
||||
};
|
||||
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
|
||||
}
|
||||
Part::Drop(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a type that always collects `component` gives when its NBT leaves the
|
||||
/// value out (its empty value, which the NBT omits).
|
||||
fn unset(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// An `Items` entry as a `minecraft:container` slot.
|
||||
fn slot_entry(entry: &Tag) -> Option<Tag> {
|
||||
let Tag::Compound(entry) = entry else {
|
||||
return None;
|
||||
};
|
||||
let mut item = entry.clone();
|
||||
let slot = match item.remove(&JavaString::from("Slot"))? {
|
||||
Tag::Byte(s) => i32::from(s),
|
||||
_ => return None,
|
||||
};
|
||||
Some(container_slot(slot, item))
|
||||
}
|
||||
|
||||
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
|
||||
fn container_slot(slot: i32, mut item: Compound) -> Tag {
|
||||
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
|
||||
item.remove(&JavaString::from("count"));
|
||||
}
|
||||
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
|
||||
}
|
||||
|
||||
/// The value of `component` on the clone item of a type that collects it: a
|
||||
/// picked item leaves it out of its patch, and a placed item without it in its
|
||||
/// patch gives it to the block entity.
|
||||
fn item_default(component: &str) -> Option<Tag> {
|
||||
Some(match component {
|
||||
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
|
||||
"minecraft:pot_decorations" => {
|
||||
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
|
||||
Tag::compound([
|
||||
("back", brick()),
|
||||
("front", brick()),
|
||||
("left", brick()),
|
||||
("right", brick()),
|
||||
])
|
||||
}
|
||||
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
|
||||
block_entity_nbt::sign_side(None)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
|
||||
|
||||
/// `saveCustomOnly`: the saved NBT without its id, position and components.
|
||||
fn custom_only(saved: &Tag) -> Compound {
|
||||
match saved {
|
||||
Tag::Compound(c) => c
|
||||
.iter()
|
||||
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
_ => Compound::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut patch = Compound::new();
|
||||
// BlockItem.setBlockEntityData after removeComponentsFromTag.
|
||||
let mut data = custom_only(saved);
|
||||
for part in parts(&id) {
|
||||
let key = match *part {
|
||||
Part::Same(key, _) | Part::Drop(key) => key,
|
||||
Part::Items(_) => "Items",
|
||||
Part::Loot => {
|
||||
data.remove(&JavaString::from("LootTableSeed"));
|
||||
"LootTable"
|
||||
}
|
||||
Part::Waxed => "is_waxed",
|
||||
Part::PotItem => "item",
|
||||
};
|
||||
data.remove(&JavaString::from(key));
|
||||
}
|
||||
if !data.is_empty() {
|
||||
data.insert("id".into(), Tag::String(id.as_str().into()));
|
||||
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
|
||||
}
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
patch.insert(component, value);
|
||||
}
|
||||
}
|
||||
patch
|
||||
}
|
||||
|
||||
/// The block entity saved as `saved` after an item with component patch
|
||||
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
|
||||
/// item's data of the same type, op-only types only when `game_master`, the
|
||||
/// player can use game master blocks), then `applyComponentsFromItemStack`.
|
||||
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
|
||||
let Some(id) = block_entity_nbt::id(saved) else {
|
||||
return saved.clone();
|
||||
};
|
||||
let Tag::Compound(mut tag) = saved.clone() else {
|
||||
return saved.clone();
|
||||
};
|
||||
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
|
||||
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
&& data
|
||||
.get(&JavaString::from("id"))
|
||||
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
|
||||
&& (game_master || !OP_ONLY.contains(&id.as_str()))
|
||||
{
|
||||
let custom = custom_only(saved);
|
||||
let mut merged = custom.clone();
|
||||
let mut data = data.clone();
|
||||
data.remove(&JavaString::from("id"));
|
||||
crate::data_cmd::merge_compound(&mut merged, &data);
|
||||
if merged != custom {
|
||||
for key in METADATA {
|
||||
if let Some(v) = tag.get(&JavaString::from(key)) {
|
||||
merged.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
|
||||
tag = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// applyImplicitComponents, then the rest of the patch as the block
|
||||
// entity's own components.
|
||||
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
|
||||
// The item's components: its patch over its own defaults.
|
||||
let get = |c: &str| match components.get(&JavaString::from(c)) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
|
||||
None => item_default(c),
|
||||
};
|
||||
let mut set = |key: &str, value: Option<Tag>| match value {
|
||||
Some(v) => tag.insert(key.into(), v),
|
||||
None => tag.remove(&JavaString::from(key)),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
match *part {
|
||||
Part::Same(key, component) => {
|
||||
read.push(component);
|
||||
set(key, get(component));
|
||||
}
|
||||
Part::Items(size) => {
|
||||
read.push(CONTAINER);
|
||||
let items = container_slots(get(CONTAINER).as_ref())
|
||||
.filter(|(slot, _)| (*slot as usize) < size)
|
||||
.map(|(slot, mut item)| {
|
||||
item.insert("Slot".into(), Tag::Byte(slot as i8));
|
||||
Tag::Compound(item)
|
||||
})
|
||||
.collect();
|
||||
set("Items", Some(Tag::List(items)));
|
||||
}
|
||||
Part::Loot => {
|
||||
read.push("minecraft:container_loot");
|
||||
if let Some(loot) = get("minecraft:container_loot") {
|
||||
set("LootTable", loot.get("loot_table").cloned());
|
||||
set("LootTableSeed", loot.get("seed").cloned());
|
||||
}
|
||||
}
|
||||
Part::Waxed => {
|
||||
read.push("minecraft:waxed");
|
||||
set(
|
||||
"is_waxed",
|
||||
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
|
||||
);
|
||||
}
|
||||
Part::PotItem => {
|
||||
read.push(CONTAINER);
|
||||
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
|
||||
set("item", first.map(|(_, item)| Tag::Compound(item)));
|
||||
}
|
||||
Part::Drop(_) => {}
|
||||
}
|
||||
}
|
||||
let own: Compound = components
|
||||
.iter()
|
||||
.filter(|(k, _)| {
|
||||
let k = k.to_string_lossy();
|
||||
!k.starts_with('!') && !read.contains(&k.as_str())
|
||||
})
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
tag.insert("components".into(), Tag::Compound(own));
|
||||
let tag = Tag::Compound(tag);
|
||||
canonical(&tag).unwrap_or(tag)
|
||||
}
|
||||
|
||||
/// The slots of a `minecraft:container` component.
|
||||
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
|
||||
let list = match container {
|
||||
Some(Tag::List(list)) => list.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
list.iter().filter_map(|entry| {
|
||||
let slot = match entry.get("slot")? {
|
||||
Tag::Int(s) => *s,
|
||||
_ => return None,
|
||||
};
|
||||
match entry.get("item")? {
|
||||
Tag::Compound(item) => Some((slot, item.clone())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snbt(text: &str) -> Tag {
|
||||
horizon_world::nbt::parse_snbt(text).expect("snbt").0
|
||||
}
|
||||
fn saved(text: &str) -> Tag {
|
||||
canonical(&snbt(text)).expect("known type")
|
||||
}
|
||||
fn canon(patch: &Compound) -> String {
|
||||
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
|
||||
let chest = saved(
|
||||
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
|
||||
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
|
||||
);
|
||||
let patch = pick(&chest);
|
||||
assert_eq!(
|
||||
canon(&patch),
|
||||
crate::item_data::canon_tag(&snbt(
|
||||
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
|
||||
\"minecraft:custom_name\":\"Loot\"}"
|
||||
))
|
||||
);
|
||||
// Placing it back gives the same chest.
|
||||
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
|
||||
assert_eq!(place(&empty, &patch, false), chest);
|
||||
// An empty chest has nothing to carry.
|
||||
assert!(pick(&empty).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_block_keeps_its_command_in_block_entity_data() {
|
||||
let block = saved(
|
||||
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
|
||||
);
|
||||
let patch = pick(&block);
|
||||
let data = patch
|
||||
.get(&JavaString::from(BLOCK_ENTITY_DATA))
|
||||
.expect("data");
|
||||
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
|
||||
assert_eq!(data.get("powered"), None);
|
||||
assert_eq!(
|
||||
data.get("id"),
|
||||
Some(&Tag::String("minecraft:command_block".into()))
|
||||
);
|
||||
// Only a player who can use game master blocks places it.
|
||||
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), fresh);
|
||||
assert_eq!(
|
||||
place(&fresh, &patch, true).get("Command"),
|
||||
Some(&Tag::String("say hi".into()))
|
||||
);
|
||||
// Data of another type is ignored.
|
||||
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&chest, &patch, true), chest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_sign_gives_its_text_and_wax() {
|
||||
let sign = saved(
|
||||
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
|
||||
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
|
||||
);
|
||||
let patch = pick(&sign);
|
||||
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
|
||||
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
|
||||
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
|
||||
assert_eq!(place(&fresh, &patch, false), sign);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
|
||||
@@ -10,6 +10,7 @@ mod banners;
|
||||
mod beacon;
|
||||
mod beehives;
|
||||
mod block_entity_anims;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boats;
|
||||
mod books;
|
||||
@@ -2569,19 +2570,21 @@ impl Game<'_> {
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) {
|
||||
let sign = matches!(entity, BlockEntity::Sign(_));
|
||||
let command_block = matches!(entity, BlockEntity::CommandBlock(_));
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((target[0], target[1], target[2]), entity);
|
||||
if command_block {
|
||||
self.command_block_placed((target[0], target[1], target[2]));
|
||||
}
|
||||
self.fill_placed_box(target, &stack);
|
||||
self.name_placed_container(target, &stack);
|
||||
self.fill_placed_banner(target, &stack);
|
||||
if sign {
|
||||
self.open_sign_editor((target[0], target[1], target[2]), true);
|
||||
let key = (target[0], target[1], target[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
let game_master = self.can_use_game_master_blocks();
|
||||
self.apply_placed_item(target, &stack, game_master);
|
||||
// Block.setPlacedBy.
|
||||
match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::CommandBlock(_)) => {
|
||||
let data = stack.data().is_some_and(|d| {
|
||||
d.other(horizon_client::block_entity_item::BLOCK_ENTITY_DATA)
|
||||
.is_some()
|
||||
});
|
||||
self.command_block_placed(key, !data);
|
||||
}
|
||||
Some(BlockEntity::Sign(sign)) if !sign.waxed => self.open_sign_editor(key, true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
@@ -8901,17 +8904,11 @@ impl Game<'_> {
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
|
||||
// the block's clone item, picked from the inventory (`pick_item`).
|
||||
// the block's clone item, with its block entity's data on Ctrl in
|
||||
// creative, picked from the inventory (`pick_item`).
|
||||
if let Some(hit) = self.pick() {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let item = self.res.facts.get(state).item.clone();
|
||||
if &*item != "minecraft:air" {
|
||||
let stack = ItemStack {
|
||||
item,
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let include_data = self.creative && self.has_control_down();
|
||||
if let Some(stack) = self.picked_stack(hit.pos, include_data) {
|
||||
let infinite = self.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut self.inventory[..36],
|
||||
|
||||
@@ -5,7 +5,6 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
banner::{self, BannerLayer},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -17,19 +16,6 @@ impl Game<'_> {
|
||||
};
|
||||
layer_textures(&self.res.stations.banners, &b.layers)
|
||||
}
|
||||
/// A placed banner takes the item's layers and name.
|
||||
pub(super) fn fill_placed_banner(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
self.pending.banner.remove(&pos);
|
||||
if !stack.item.ends_with("_banner") {
|
||||
return;
|
||||
}
|
||||
if let Some(BlockEntity::Banner(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.layers = banner::layers(stack).to_vec();
|
||||
b.name = stack.custom_name().map(str::to_owned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture names and tints of banner layers (`Sheets.getBannerSprite`).
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Block entities and the items that carry them
|
||||
//! (`horizon_client::block_entity_item`): the item Pick Block gives, with the
|
||||
//! block entity's data on Ctrl in creative, and the data and components a
|
||||
//! placed item gives its block entity.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
|
||||
};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// The component patch of `stack`.
|
||||
fn components(stack: &ItemStack) -> Compound {
|
||||
match item_data::encode_components(stack) {
|
||||
Some(Tag::Compound(c)) => c,
|
||||
_ => Compound::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The block entity at `key` as vanilla saves it, with its custom name.
|
||||
fn saved_block_entity(&self, key: (i32, i32, i32)) -> Option<Tag> {
|
||||
let entity = self.world.block_entities.get(&key)?;
|
||||
let block = &self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(key.0, key.1, key.2))
|
||||
.name;
|
||||
let mut tag = save::encode_block_entity(key, entity, block);
|
||||
if let (Some(name), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.insert("CustomName".into(), Tag::String(name.as_str().into()));
|
||||
}
|
||||
block_entity_nbt::canonical(&tag)
|
||||
}
|
||||
|
||||
/// `BlockState.getCloneItemStack` of the block at `pos`, then, with
|
||||
/// `include_data`, `ServerGamePacketListenerImpl.addBlockDataToItem`.
|
||||
pub(super) fn picked_stack(&self, pos: [i32; 3], include_data: bool) -> Option<ItemStack> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let facts = self.res.facts.get(state);
|
||||
if &*facts.item == "minecraft:air" {
|
||||
return None;
|
||||
}
|
||||
let mut stack = ItemStack::new(&facts.item, 1);
|
||||
let mut patch = Compound::new();
|
||||
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
|
||||
if include_data
|
||||
&& let Some(level) = self
|
||||
.res
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| matches!(s.name.as_str(), "minecraft:beehive" | "minecraft:bee_nest"))
|
||||
.and_then(|s| s.properties.get("honey_level"))
|
||||
{
|
||||
patch.insert(
|
||||
"minecraft:block_state".into(),
|
||||
Tag::compound([("honey_level", Tag::String(level.as_str().into()))]),
|
||||
);
|
||||
}
|
||||
// AbstractBannerBlock.getCloneItemStack: the banner's own item
|
||||
// (BannerBlockEntity.getItem), data or not.
|
||||
let banner = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Banner(_))
|
||||
);
|
||||
if (include_data || banner)
|
||||
&& let Some(saved) = self.saved_block_entity(key)
|
||||
{
|
||||
patch.extend(block_entity_item::pick(&saved));
|
||||
}
|
||||
if !patch.is_empty() {
|
||||
let mut all = components(&stack);
|
||||
all.extend(patch);
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(all)));
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
|
||||
/// for the block entity just placed at `pos` from `stack`; `game_master`:
|
||||
/// the placer can use game master blocks (a dispenser can't).
|
||||
pub(super) fn apply_placed_item(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
stack: &ItemStack,
|
||||
game_master: bool,
|
||||
) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
self.pending.banner.remove(&pos);
|
||||
let Some(saved) = self.saved_block_entity(key) else {
|
||||
return;
|
||||
};
|
||||
let placed = block_entity_item::place(&saved, &components(stack), game_master);
|
||||
if placed == saved {
|
||||
return;
|
||||
}
|
||||
let Some((_, mut entity)) = save::decode_block_entity(&placed) else {
|
||||
return;
|
||||
};
|
||||
// The new crafter's state flag (CrafterBlock.newBlockEntity), which
|
||||
// its saved data has no part of.
|
||||
if let (BlockEntity::Crafter(new), Some(BlockEntity::Crafter(old))) =
|
||||
(&mut entity, self.world.block_entities.get(&key))
|
||||
{
|
||||
new.state_triggered = old.state_triggered;
|
||||
}
|
||||
// Banners and signs keep their name themselves.
|
||||
if !matches!(entity, BlockEntity::Banner(_) | BlockEntity::Sign(_)) {
|
||||
match save::text(placed.get("CustomName")) {
|
||||
Some(name) => self.world.block_names.insert(key, name),
|
||||
None => self.world.block_names.remove(&key),
|
||||
};
|
||||
}
|
||||
self.world.block_entities.insert(key, entity);
|
||||
}
|
||||
}
|
||||
@@ -210,15 +210,16 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
|
||||
/// `CommandBlock.setPlacedBy`: output tracking follows
|
||||
/// `send_command_feedback`, then the block takes the signal around it.
|
||||
pub(super) fn command_block_placed(&mut self, pos: Pos) {
|
||||
/// `CommandBlock.setPlacedBy`: unless the item carried block entity data
|
||||
/// (`defaults` false), output tracking follows `send_command_feedback` and
|
||||
/// the block's own automatic flag; then the block takes the signal around it.
|
||||
pub(super) fn command_block_placed(&mut self, pos: Pos, defaults: bool) {
|
||||
let feedback = self.ui.rules.bool("send_command_feedback");
|
||||
let Some(name) = self.block_state_at(pos).map(|s| s.name.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let auto = command_block::automatic_block(name);
|
||||
if let Some(c) = command_block_mut(&mut self.world, pos) {
|
||||
if defaults && let Some(c) = command_block_mut(&mut self.world, pos) {
|
||||
c.track_output = feedback;
|
||||
c.auto = auto;
|
||||
}
|
||||
|
||||
@@ -1541,8 +1541,7 @@ impl Game<'_> {
|
||||
let target = (placed.x, placed.y, placed.z);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) {
|
||||
self.world.block_entities.insert(target, entity);
|
||||
self.fill_placed_box([target.0, target.1, target.2], &stack);
|
||||
self.name_placed_container([target.0, target.1, target.2], &stack);
|
||||
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
|
||||
}
|
||||
self.process_level_events()?;
|
||||
Ok((shrink(stack), CLICK))
|
||||
|
||||
@@ -2278,7 +2278,7 @@ impl Game<'_> {
|
||||
if let Some(hit) = self.pick() {
|
||||
self.send(S::PickItemFromBlock(play::PickItemFromBlock {
|
||||
pos: hit.pos,
|
||||
include_data: false,
|
||||
include_data: self.has_control_down(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,6 @@ pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// A placed container takes the item's custom name.
|
||||
pub(super) fn name_placed_container(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if let Some(name) = stack.custom_name()
|
||||
&& !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Banner(_) | BlockEntity::Sign(_))
|
||||
)
|
||||
{
|
||||
self.world.block_names.insert(key, name.to_owned());
|
||||
}
|
||||
}
|
||||
/// The custom name of the open container, or its default title
|
||||
/// (a double chest is named by whichever half has a name).
|
||||
pub(super) fn container_title(&self, default: String) -> String {
|
||||
@@ -29,22 +17,6 @@ impl Game<'_> {
|
||||
.cloned()
|
||||
.unwrap_or(default)
|
||||
}
|
||||
/// A placed shulker box takes the item's contents.
|
||||
pub(super) fn fill_placed_box(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
let Some(data) = stack.data() else { return };
|
||||
if !is_shulker_box(&stack.item) || data.container.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(BlockEntity::Chest(items)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
for (slot, s) in &data.container {
|
||||
if let Some(cell) = items.get_mut(usize::from(*slot)) {
|
||||
*cell = Some(s.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ShulkerBoxSlot.mayPlace: no shulker box inside a shulker box.
|
||||
pub(super) fn refuses(&self, slot: usize, stack: &ItemStack) -> bool {
|
||||
(300..400).contains(&slot)
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod biome;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_entity_nbt;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_transform;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponentPatch;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.TagParser;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.ProblemReporter;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.TagValueOutput;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Original oracle: block entity data on items. Each case line is JSON: a block,
|
||||
* the saved NBT of its block entity, the block entity an item is placed as
|
||||
* ("target", NBT), the item's component patch ("patch", SNBT; absent: the item
|
||||
* Ctrl + Pick Block gave) and whether the player can use game master blocks.
|
||||
* Prints the picked item's component patch, a tab, then the placed block
|
||||
* entity's saveWithFullMetadata.
|
||||
*/
|
||||
public final class VanillaPickBlockDataOracle {
|
||||
static final Logger LOGGER = LogUtils.getLogger();
|
||||
static final BlockPos POS = new BlockPos(0, 64, 0);
|
||||
|
||||
static BlockEntity load(BlockState state, String snbt) throws Exception {
|
||||
BlockEntity entity = BlockEntity.loadStatic(POS, state, TagParser.parseCompoundFully(snbt), ParityData.access);
|
||||
if (entity == null) throw new IllegalStateException("no block entity: " + snbt);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** ServerGamePacketListenerImpl.addBlockDataToItem on the block's item. */
|
||||
static ItemStack pick(BlockState state, BlockEntity entity) {
|
||||
ItemStack item = new ItemStack(state.getBlock().asItem());
|
||||
try (ProblemReporter.ScopedCollector reporter = new ProblemReporter.ScopedCollector(entity.problemPath(), LOGGER)) {
|
||||
TagValueOutput output = TagValueOutput.createWithContext(reporter, ParityData.access);
|
||||
entity.saveCustomOnly(output);
|
||||
entity.removeComponentsFromTag(output);
|
||||
BlockItem.setBlockEntityData(item, entity.getType(), output);
|
||||
item.applyComponents(entity.collectComponents());
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** BlockItem.updateCustomBlockEntityTag, then updateBlockEntityComponents. */
|
||||
static void place(BlockEntity entity, ItemStack item, boolean gameMaster) {
|
||||
var data = item.get(DataComponents.BLOCK_ENTITY_DATA);
|
||||
if (data != null && data.type() == entity.getType() && (!entity.getType().onlyOpCanSetNbt() || gameMaster)) {
|
||||
data.loadInto(entity, ParityData.access);
|
||||
}
|
||||
entity.applyComponentsFromItemStack(item);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
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<String>) lines::iterator) {
|
||||
var c = JsonParser.parseString(line).getAsJsonObject();
|
||||
try {
|
||||
BlockState state = BuiltInRegistries.BLOCK.getValue(Identifier.parse(c.get("block").getAsString())).defaultBlockState();
|
||||
ItemStack picked = pick(state, load(state, c.get("nbt").getAsString()));
|
||||
ItemStack item = picked;
|
||||
if (c.has("patch")) {
|
||||
var patch = DataComponentPatch.CODEC.parse(ParityData.ops, TagParser.parseCompoundFully(c.get("patch").getAsString())).getOrThrow();
|
||||
item = new ItemStack(state.getBlock().asItem());
|
||||
item.applyComponents(patch);
|
||||
}
|
||||
BlockEntity target = load(state, c.get("target").getAsString());
|
||||
place(target, item, c.get("op").getAsBoolean());
|
||||
CompoundTag saved = target.saveWithFullMetadata(ParityData.access);
|
||||
var patch = DataComponentPatch.CODEC.encodeStart(ParityData.ops, picked.getComponentsPatch()).getOrThrow();
|
||||
out.println(ParityData.canon(patch) + "\t" + ParityData.canon(saved));
|
||||
} catch (Exception e) {
|
||||
System.err.println(line + ": " + e);
|
||||
out.println("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare block entity data on items with vanilla 26.3.
|
||||
|
||||
The Java oracle (VanillaPickBlockDataOracle) loads each block entity with
|
||||
BlockEntity.loadStatic and runs ServerGamePacketListenerImpl.addBlockDataToItem
|
||||
(Ctrl + Pick Block in creative) on the block's item, then places an item as a
|
||||
block entity: BlockItem.updateCustomBlockEntityTag (op-only types only for a
|
||||
game master) and BlockEntity.applyComponentsFromItemStack. The Rust example
|
||||
(pick_block_data_fixture) runs horizon_client::block_entity_item on the
|
||||
canonical saved NBT. The picked component patch and the placed block entity's
|
||||
saveWithFullMetadata are compared for every block entity type that carries data:
|
||||
containers with items, names, locks and loot tables, signs, banners, command
|
||||
blocks, spawners, trial spawners, vaults, beehives, pots, skulls, lecterns and
|
||||
more, picked and placed back, with hand-written patches (data of another type,
|
||||
op-only data without permission, merges into a filled block entity, leftover
|
||||
components).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
try:
|
||||
from . import interaction_lab as lib
|
||||
except ImportError:
|
||||
import interaction_lab as lib
|
||||
|
||||
POS = 'x:0,y:64,z:0'
|
||||
STONE = '{id:"minecraft:stone",count:5}'
|
||||
NAME = 'CustomName:"Box"'
|
||||
PIG = 'SpawnData:{entity:{id:"minecraft:pig"}}'
|
||||
# A block entity that spawns from nothing: what loading it saves (a spawner's
|
||||
# potentials and a trial spawner's next spawn come from its first spawn data).
|
||||
FRESH = {'mob_spawner': PIG, 'trial_spawner': 'spawn_data:{entity:{}}'}
|
||||
|
||||
|
||||
def be(type_id, body=''):
|
||||
return '{id:"minecraft:%s",%s%s}' % (type_id, POS, ',' + body if body else '')
|
||||
|
||||
|
||||
def items(*slots):
|
||||
return 'Items:[%s]' % ','.join('{Slot:%db,id:"minecraft:%s",count:%d}' % s for s in slots)
|
||||
|
||||
|
||||
# (block, block entity type, saved NBT bodies to pick and place back).
|
||||
SAMPLES = [
|
||||
('chest', 'chest', ['', items((0, 'stone', 5), (26, 'diamond', 1)), NAME, 'lock:{items:"minecraft:diamond"}',
|
||||
'LootTable:"minecraft:chests/simple_dungeon",LootTableSeed:42L',
|
||||
'LootTable:"minecraft:chests/igloo_chest"', items((3, 'dirt', 1)) + ',' + NAME]),
|
||||
('trapped_chest', 'trapped_chest', [items((1, 'stone', 2))]),
|
||||
('barrel', 'barrel', [items((5, 'apple', 3)) + ',' + NAME]),
|
||||
('red_shulker_box', 'shulker_box', ['', items((0, 'stone', 64), (13, 'emerald', 2)) + ',' + NAME]),
|
||||
('hopper', 'hopper', ['TransferCooldown:5', items((4, 'stone', 1)) + ',TransferCooldown:2']),
|
||||
('dispenser', 'dispenser', [items((8, 'arrow', 16))]),
|
||||
('dropper', 'dropper', [items((0, 'stone', 1)) + ',' + NAME]),
|
||||
('crafter', 'crafter', ['disabled_slots:[I;1,4],triggered:1,crafting_ticks_remaining:3',
|
||||
items((2, 'stick', 1)) + ',disabled_slots:[I;0]']),
|
||||
('furnace', 'furnace', ['cooking_time_spent:20,cooking_total_time:200,lit_time_remaining:100,lit_total_time:1600,'
|
||||
+ items((0, 'iron_ore', 3), (1, 'coal', 4)) + ',RecipesUsed:{"minecraft:iron_ingot":2}']),
|
||||
('smoker', 'smoker', [items((2, 'cooked_beef', 1))]),
|
||||
('blast_furnace', 'blast_furnace', [NAME + ',lit_time_remaining:7']),
|
||||
('brewing_stand', 'brewing_stand', ['BrewTime:30,Fuel:10,' + items((4, 'blaze_powder', 1))]),
|
||||
# Levels is computed by the beacon's tick, never loaded.
|
||||
('beacon', 'beacon', ['primary_effect:"minecraft:speed",secondary_effect:"minecraft:regeneration"',
|
||||
NAME + ',lock:{items:"minecraft:stick"}']),
|
||||
('enchanting_table', 'enchanting_table', ['', NAME]),
|
||||
('command_block', 'command_block', ['Command:"say hi",auto:1b,powered:1b,conditionMet:1b,TrackOutput:0b,SuccessCount:3',
|
||||
'Command:"time set day",' + NAME, 'LastOutput:"{\\"text\\":\\"x\\"}"',
|
||||
'UpdateLastExecution:0b,LastExecution:20L']),
|
||||
('repeating_command_block', 'command_block', ['Command:"say loop",auto:1b']),
|
||||
('campfire', 'campfire', [items((0, 'beef', 1), (3, 'porkchop', 1)) + ',CookingTimes:[I;5,0,0,9],CookingTotalTimes:[I;600,0,0,600]']),
|
||||
('chiseled_bookshelf', 'chiseled_bookshelf', [items((0, 'book', 1), (5, 'enchanted_book', 1)) + ',last_interacted_slot:5']),
|
||||
('oak_shelf', 'shelf', [items((1, 'stone', 1)) + ',align_items_to_bottom:1b']),
|
||||
('beehive', 'beehive', ['', 'bees:[{entity_data:{id:"minecraft:bee"},ticks_in_hive:5,min_ticks_in_hive:600}]',
|
||||
'flower_pos:[I;1,2,3]']),
|
||||
('white_banner', 'banner', ['', 'patterns:[{pattern:"minecraft:creeper",color:"red"}]',
|
||||
'patterns:[{pattern:"minecraft:stripe_top",color:"blue"}],' + NAME]),
|
||||
('decorated_pot', 'decorated_pot', ['', 'sherds:{back:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd"}}',
|
||||
'item:{id:"minecraft:diamond",count:3}', 'LootTable:"minecraft:archaeology/desert_pyramid"']),
|
||||
('oak_sign', 'sign', ['', 'front_text:{messages:["hello","world","",""]}',
|
||||
'front_text:{messages:["a","","",""],color:"red",has_glowing_text:1b},back_text:{messages:["","b","",""]},is_waxed:1b']),
|
||||
('oak_hanging_sign', 'hanging_sign', ['back_text:{messages:["x","","",""]}']),
|
||||
('skeleton_skull', 'skull', ['', 'custom_name:"Bones"', 'note_block_sound:"minecraft:block.note_block.bell"']),
|
||||
('spawner', 'mob_spawner', ['Delay:20,SpawnData:{entity:{id:"minecraft:zombie"}},MinSpawnDelay:200,MaxSpawnDelay:800',
|
||||
'SpawnData:{entity:{id:"minecraft:pig"}},SpawnCount:2,RequiredPlayerRange:8']),
|
||||
('trial_spawner', 'trial_spawner', ['spawn_data:{entity:{id:"minecraft:zombie"}}',
|
||||
'normal_config:"minecraft:trial_chamber/melee/zombie/normal",required_player_range:10,spawn_data:{entity:{}}']),
|
||||
('vault', 'vault', ['', 'config:{key_item:{id:"minecraft:ominous_trial_key",count:1}}']),
|
||||
('jukebox', 'jukebox', ['', 'RecordItem:{id:"minecraft:music_disc_cat",count:1},ticks_since_song_started:40L']),
|
||||
('lectern', 'lectern', ['', 'Book:{id:"minecraft:writable_book",count:1,components:{"minecraft:writable_book_content":{pages:[{raw:"a"},{raw:"b"}]}}},Page:1']),
|
||||
('sculk_sensor', 'sculk_sensor', ['last_vibration_frequency:4']),
|
||||
('creaking_heart', 'creaking_heart', ['']),
|
||||
('bell', 'bell', ['']),
|
||||
('structure_block', 'structure_block', ['name:"minecraft:house",mode:"SAVE",posY:2']),
|
||||
]
|
||||
|
||||
# Patches placed as (block, block entity type, target body, patch SNBT, game master).
|
||||
DATA = '"minecraft:block_entity_data"'
|
||||
PATCHES = [
|
||||
('command_block', 'command_block', '', '{%s:{id:"minecraft:command_block",Command:"say op"}}' % DATA, False),
|
||||
('command_block', 'command_block', '', '{%s:{id:"minecraft:chest",Command:"say wrong"}}' % DATA, True),
|
||||
('command_block', 'command_block', 'Command:"old",auto:1b', '{%s:{id:"minecraft:command_block",TrackOutput:0b}}' % DATA, True),
|
||||
('oak_sign', 'sign', '', '{%s:{id:"minecraft:sign",is_waxed:1b}}' % DATA, False),
|
||||
('oak_sign', 'sign', '', '{%s:{id:"minecraft:sign",is_waxed:1b}}' % DATA, True),
|
||||
('chest', 'chest', items((0, 'stone', 1)), '{%s:{id:"minecraft:chest",Items:[{Slot:1b,id:"minecraft:dirt",count:1}]}}' % DATA, True),
|
||||
('chest', 'chest', '', '{"minecraft:container":[{slot:30,item:%s},{slot:2,item:%s}]}' % (STONE, STONE), False),
|
||||
('chest', 'chest', '', '{"minecraft:container_loot":{loot_table:"minecraft:chests/simple_dungeon",seed:7L}}', False),
|
||||
('chest', 'chest', '', '{"minecraft:lore":["a"],"minecraft:custom_name":"Named"}', False),
|
||||
('hopper', 'hopper', 'TransferCooldown:4', '{%s:{id:"minecraft:hopper",TransferCooldown:9}}' % DATA, False),
|
||||
('spawner', 'mob_spawner', PIG, '{%s:{id:"minecraft:mob_spawner",SpawnData:{entity:{id:"minecraft:skeleton"}}}}' % DATA, True),
|
||||
('spawner', 'mob_spawner', PIG, '{%s:{id:"minecraft:mob_spawner",SpawnData:{entity:{id:"minecraft:skeleton"}}}}' % DATA, False),
|
||||
('trial_spawner', 'trial_spawner', 'spawn_data:{entity:{}}', '{%s:{id:"minecraft:trial_spawner",required_player_range:4}}' % DATA, True),
|
||||
('vault', 'vault', '', '{%s:{id:"minecraft:vault",config:{key_item:{id:"minecraft:stick",count:1}}}}' % DATA, False),
|
||||
('decorated_pot', 'decorated_pot', '', '{"minecraft:container":[{slot:1,item:%s}]}' % STONE, False),
|
||||
('white_banner', 'banner', NAME, '{"minecraft:banner_patterns":[{pattern:"minecraft:flower",color:"lime"}]}', False),
|
||||
('beehive', 'beehive', 'bees:[{entity_data:{id:"minecraft:bee"},ticks_in_hive:1,min_ticks_in_hive:2}]', '{}', False),
|
||||
('decorated_pot', 'decorated_pot', '', '{"!minecraft:pot_decorations":{}}', False),
|
||||
('hopper', 'hopper', items((0, 'stone', 1)), '{"minecraft:container":[{slot:4,item:%s},{slot:5,item:%s}]}' % (STONE, STONE), False),
|
||||
('oak_sign', 'sign', '', '{"minecraft:waxed":{},"minecraft:sign_text_back":{messages:["","","","z"]}}', False),
|
||||
]
|
||||
|
||||
|
||||
def cases():
|
||||
out = []
|
||||
for block, type_id, bodies in SAMPLES:
|
||||
for body in bodies:
|
||||
for op in (True, False):
|
||||
out.append({'block': 'minecraft:' + block, 'nbt': be(type_id, body),
|
||||
'target': be(type_id, FRESH.get(type_id, '')), 'op': op})
|
||||
for block, type_id, target, patch, op in PATCHES:
|
||||
out.append({'block': 'minecraft:' + block, 'nbt': be(type_id, FRESH.get(type_id, '')), 'target': be(type_id, target), 'patch': patch, 'op': op})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--limit', type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
work = lib.work_dir('pick-block-data-parity')
|
||||
vanilla = lib.Vanilla(work)
|
||||
vanilla.compile('ParityData', 'VanillaPickBlockDataOracle')
|
||||
all_cases = cases()
|
||||
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in all_cases))
|
||||
vanilla.run('VanillaPickBlockDataOracle', work / 'cases.jsonl', work / 'native.txt')
|
||||
lib.cargo_example('horizon-client', 'pick_block_data_fixture', work / 'cases.jsonl', work / 'rust.txt')
|
||||
errors = sum(line == 'error' for line in (work / 'native.txt').read_text().split('\n'))
|
||||
if errors:
|
||||
print(f'{errors} cases fail in vanilla')
|
||||
total = lib.compare(all_cases, work / 'native.txt', work / 'rust.txt',
|
||||
lambda case, line: case['block'] + (' patch' if 'patch' in case else ''),
|
||||
lambda case: json.dumps(case), args.limit)
|
||||
raise SystemExit(1 if total or errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user