1use std::sync::Arc;
4
5use simdnbt::owned::NbtTag;
6use steel_registry::{blocks::block_state_ext::BlockStateExt as _, vanilla_blocks};
7use steel_utils::{
8 BlockPos, BoundingBox, ChunkPos, SectionPos,
9 nbt::{NbtPath, compare_nbt_compounds},
10 translations,
11};
12use text_components::TextComponent;
13
14use super::super::super::{
15 brigadier::{CommandNodeBuilder, CommandRedirectTarget, CommandSyntaxError},
16 execution::{
17 CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
18 literal,
19 },
20};
21use super::{objective, source_command_storage, source_scoreboard};
22use crate::{block_entity::SharedBlockEntity, world::World};
23
24type Builder = CommandNodeBuilder<CommandSource, SteelCommandRuntime>;
25
26const EXECUTE_ROOT: CommandRedirectTarget = CommandRedirectTarget::CommandRoot;
27const MAX_BLOCKS_REGION: i64 = 32_768;
28
29pub(super) fn conditionals(name: &'static str, expected: bool) -> Builder {
30 literal(name)
35 .then(biome_condition(expected))
36 .then(block_condition(expected))
37 .then(blocks_condition(expected))
38 .then(data_condition(expected))
39 .then(dimension_condition(expected))
40 .then(entity_condition(expected))
41 .then(loaded_condition(expected))
42 .then(score_condition(expected))
43}
44
45fn data_condition(expected: bool) -> Builder {
46 literal("data")
47 .then(
48 literal("block").then(
49 argument("sourcePos", SteelArgumentType::block_pos())
50 .then(data_path(DataSource::Block, expected)),
51 ),
52 )
53 .then(
54 literal("entity").then(
55 argument("source", SteelArgumentType::entity())
56 .then(data_path(DataSource::Entity, expected)),
57 ),
58 )
59 .then(
60 literal("storage").then(
61 argument("source", SteelArgumentType::storage_key())
62 .then(data_path(DataSource::Storage, expected)),
63 ),
64 )
65}
66
67fn data_path(source: DataSource, expected: bool) -> Builder {
68 argument("path", SteelArgumentType::nbt_path())
69 .forks(EXECUTE_ROOT, move |context| {
70 let matches = data_match_count(context, source)? > 0;
71 Ok(conditional_sources(context.source(), expected, matches))
72 })
73 .executes(move |context| {
74 let count = data_match_count(context, source)?;
75 execute_numeric_condition(context, expected, count)
76 })
77}
78
79#[derive(Clone, Copy)]
80enum DataSource {
81 Block,
82 Entity,
83 Storage,
84}
85
86fn data_match_count(
87 context: &SteelCommandContext<CommandSource>,
88 source: DataSource,
89) -> Result<i32, CommandSyntaxError> {
90 let tag = match source {
91 DataSource::Block => {
92 let position = loaded_block_position(context, "sourcePos")?;
93 let block_entity = context
94 .source()
95 .world()
96 .get_block_entity(position)
97 .ok_or_else(invalid_block_data_source)?;
98 let data = block_entity.save_with_full_metadata();
99 NbtTag::Compound(data)
100 }
101 DataSource::Entity => {
102 let entity = context.entity("source")?;
103 NbtTag::Compound(entity.nbt_for_data_compare())
104 }
105 DataSource::Storage => {
106 let key = context
107 .identifier("source")
108 .ok_or_else(|| missing_argument("source"))?;
109 NbtTag::Compound(source_command_storage(context)?.get(key))
110 }
111 };
112 let path = context
113 .nbt_path("path")
114 .ok_or_else(|| missing_argument("path"))?;
115 matching_data_count(path, &tag)
116}
117
118fn matching_data_count(path: &NbtPath, tag: &NbtTag) -> Result<i32, CommandSyntaxError> {
119 i32::try_from(path.count_matching(tag))
120 .map_err(|_| CommandSyntaxError::dynamic("NBT match count exceeds the command range"))
121}
122
123pub(super) fn invalid_block_data_source() -> CommandSyntaxError {
124 CommandSyntaxError::dynamic(TextComponent::from(
125 &translations::COMMANDS_DATA_BLOCK_INVALID,
126 ))
127}
128
129fn dimension_condition(expected: bool) -> Builder {
130 literal("dimension").then(
131 argument("dimension", SteelArgumentType::world())
132 .forks(EXECUTE_ROOT, move |context| {
133 let matches = dimension_matches(context)?;
134 Ok(conditional_sources(context.source(), expected, matches))
135 })
136 .executes(move |context| {
137 execute_boolean_condition(context, expected, dimension_matches(context)?)
138 }),
139 )
140}
141
142fn dimension_matches(
143 context: &SteelCommandContext<CommandSource>,
144) -> Result<bool, CommandSyntaxError> {
145 let world = context
146 .world_argument("dimension")
147 .ok_or_else(|| missing_argument("dimension"))?
148 .resolve(context.source())?;
149 Ok(Arc::ptr_eq(context.source().world(), &world))
150}
151
152fn blocks_condition(expected: bool) -> Builder {
153 literal("blocks").then(
154 argument("start", SteelArgumentType::block_pos()).then(
155 argument("end", SteelArgumentType::block_pos()).then(
156 argument("destination", SteelArgumentType::block_pos())
157 .then(blocks_mode("all", expected, false))
158 .then(blocks_mode("masked", expected, true)),
159 ),
160 ),
161 )
162}
163
164fn blocks_mode(name: &'static str, expected: bool, skip_air: bool) -> Builder {
165 literal(name)
166 .forks(EXECUTE_ROOT, move |context| {
167 let matches = matching_block_region_count(context, skip_air)?.is_some();
168 Ok(conditional_sources(context.source(), expected, matches))
169 })
170 .executes(move |context| {
171 let count = matching_block_region_count(context, skip_air)?;
172 execute_blocks_condition(context, expected, count)
173 })
174}
175
176fn matching_block_region_count(
177 context: &SteelCommandContext<CommandSource>,
178 skip_air: bool,
179) -> Result<Option<i32>, CommandSyntaxError> {
180 let source_start = loaded_block_position(context, "start")?;
181 let source_end = loaded_block_position(context, "end")?;
182 let destination_start = loaded_block_position(context, "destination")?;
183 let source_region = BoundingBox::from_corners(source_start, source_end);
184 let destination_end = destination_start.offset(
185 source_region.max_x() - source_region.min_x(),
186 source_region.max_y() - source_region.min_y(),
187 source_region.max_z() - source_region.min_z(),
188 );
189 let destination_region = BoundingBox::from_corners(destination_start, destination_end);
190 let area = block_region_volume(&source_region);
191 if area > MAX_BLOCKS_REGION {
192 return Err(blocks_too_big(area));
193 }
194
195 let world = context.source().world();
196 ensure_region_chunks_loaded(world, &source_region)?;
197 ensure_region_chunks_loaded(world, &destination_region)?;
198
199 let offset_x = destination_region.min_x() - source_region.min_x();
200 let offset_y = destination_region.min_y() - source_region.min_y();
201 let offset_z = destination_region.min_z() - source_region.min_z();
202 let mut count = 0;
203 for z in source_region.min_z()..=source_region.max_z() {
204 for y in source_region.min_y()..=source_region.max_y() {
205 for x in source_region.min_x()..=source_region.max_x() {
206 let source_pos = BlockPos::new(x, y, z);
207 let source_state = world.get_block_state(source_pos);
208 if !should_compare_block(source_state, skip_air) {
209 continue;
210 }
211 let destination_pos = source_pos.offset(offset_x, offset_y, offset_z);
212 if source_state != world.get_block_state(destination_pos)
213 || !block_entities_match(world, source_pos, destination_pos)
214 {
215 return Ok(None);
216 }
217 count += 1;
218 }
219 }
220 }
221 Ok(Some(count))
222}
223
224fn block_region_volume(region: &BoundingBox) -> i64 {
225 let x_span = i64::from(region.max_x()) - i64::from(region.min_x()) + 1;
226 let y_span = i64::from(region.max_y()) - i64::from(region.min_y()) + 1;
227 let z_span = i64::from(region.max_z()) - i64::from(region.min_z()) + 1;
228 x_span.saturating_mul(y_span).saturating_mul(z_span)
229}
230
231fn ensure_region_chunks_loaded(
233 world: &World,
234 region: &BoundingBox,
235) -> Result<(), CommandSyntaxError> {
236 if region.max_y() < world.get_min_y() || region.min_y() > world.get_max_y() {
237 return Ok(());
238 }
239 let min_chunk_x = SectionPos::block_to_section_coord(region.min_x());
240 let max_chunk_x = SectionPos::block_to_section_coord(region.max_x());
241 let min_chunk_z = SectionPos::block_to_section_coord(region.min_z());
242 let max_chunk_z = SectionPos::block_to_section_coord(region.max_z());
243 for chunk_z in min_chunk_z..=max_chunk_z {
244 for chunk_x in min_chunk_x..=max_chunk_x {
245 if !ChunkPos::is_valid(chunk_x, chunk_z) {
246 continue;
247 }
248 let pos = BlockPos::new(chunk_x * 16, world.get_min_y(), chunk_z * 16);
249 if !world.is_full_chunk_loaded_at(pos) {
250 return Err(unloaded_position());
251 }
252 }
253 }
254 Ok(())
255}
256
257fn should_compare_block(state: steel_utils::BlockStateId, skip_air: bool) -> bool {
258 !skip_air || state.get_block() != &vanilla_blocks::AIR
259}
260
261fn block_entities_match(world: &World, source: BlockPos, destination: BlockPos) -> bool {
262 let source_entity = world.get_block_entity(source);
263 let destination_entity = world.get_block_entity(destination);
264 block_entity_data_matches(source_entity.as_ref(), destination_entity.as_ref())
265}
266
267fn block_entity_data_matches(
268 source: Option<&SharedBlockEntity>,
269 destination: Option<&SharedBlockEntity>,
270) -> bool {
271 let Some(source) = source else {
272 return true;
273 };
274 let Some(destination) = destination else {
275 return false;
276 };
277 if Arc::ptr_eq(source, destination) {
278 return true;
279 }
280 if source.get_type() != destination.get_type() {
281 return false;
282 }
283 let source_data = source.save_custom_only();
284 let destination_data = destination.save_custom_only();
285 source_data.len() == destination_data.len()
286 && compare_nbt_compounds(&source_data, &destination_data, false)
287}
288
289fn execute_blocks_condition(
290 context: &SteelCommandContext<CommandSource>,
291 expected: bool,
292 count: Option<i32>,
293) -> Result<i32, CommandSyntaxError> {
294 match (expected, count) {
295 (true, Some(count)) => {
296 let message = translations::COMMANDS_EXECUTE_CONDITIONAL_PASS_COUNT
297 .message([TextComponent::from(count.to_string())])
298 .component();
299 context.source().send_success(&message, false);
300 Ok(count)
301 }
302 (true, None) => Err(conditional_failed()),
303 (false, Some(count)) => Err(conditional_failed_count(count)),
304 (false, None) => {
305 context.source().send_success(
306 &TextComponent::from(&translations::COMMANDS_EXECUTE_CONDITIONAL_PASS),
307 false,
308 );
309 Ok(1)
310 }
311 }
312}
313
314fn blocks_too_big(area: i64) -> CommandSyntaxError {
315 let message = translations::COMMANDS_EXECUTE_BLOCKS_TOOBIG
316 .message([
317 TextComponent::from(MAX_BLOCKS_REGION.to_string()),
318 TextComponent::from(area.to_string()),
319 ])
320 .component();
321 CommandSyntaxError::dynamic(message)
322}
323
324fn block_condition(expected: bool) -> Builder {
325 literal("block").then(
326 argument("pos", SteelArgumentType::block_pos()).then(
327 argument("block", SteelArgumentType::block_predicate())
328 .forks(EXECUTE_ROOT, move |context| {
329 let matches = block_matches(context)?;
330 Ok(conditional_sources(context.source(), expected, matches))
331 })
332 .executes(move |context| {
333 execute_boolean_condition(context, expected, block_matches(context)?)
334 }),
335 ),
336 )
337}
338
339fn block_matches(context: &SteelCommandContext<CommandSource>) -> Result<bool, CommandSyntaxError> {
340 let position = loaded_block_position(context, "pos")?;
341 let predicate = context
342 .block_predicate("block")
343 .ok_or_else(|| missing_argument("block"))?;
344 let world = context.source().world();
345 if !predicate.matches_state(world.get_block_state(position)) {
346 return Ok(false);
347 }
348 let Some(expected_nbt) = predicate.nbt() else {
349 return Ok(true);
350 };
351 let Some(block_entity) = world.get_block_entity(position) else {
352 return Ok(false);
353 };
354 let actual_nbt = block_entity.save_with_full_metadata();
355 Ok(compare_nbt_compounds(expected_nbt, &actual_nbt, true))
356}
357
358fn biome_condition(expected: bool) -> Builder {
359 literal("biome").then(
360 argument("pos", SteelArgumentType::block_pos()).then(
361 argument("biome", SteelArgumentType::biome_or_tag())
362 .forks(EXECUTE_ROOT, move |context| {
363 let matches = biome_matches(context)?;
364 Ok(conditional_sources(context.source(), expected, matches))
365 })
366 .executes(move |context| {
367 execute_boolean_condition(context, expected, biome_matches(context)?)
368 }),
369 ),
370 )
371}
372
373fn biome_matches(context: &SteelCommandContext<CommandSource>) -> Result<bool, CommandSyntaxError> {
374 let position = loaded_block_position(context, "pos")?;
375 let world = context.source().world();
376 let biome = world.biome_at(position).ok_or_else(|| {
377 CommandSyntaxError::dynamic(TextComponent::from(&translations::ARGUMENT_POS_UNLOADED))
378 })?;
379 let expected = context
380 .biome_or_tag("biome")
381 .ok_or_else(|| missing_argument("biome"))?;
382 Ok(expected.matches(biome))
383}
384
385pub(super) fn loaded_block_position(
386 context: &SteelCommandContext<CommandSource>,
387 name: &str,
388) -> Result<steel_utils::BlockPos, CommandSyntaxError> {
389 let position = context
390 .coordinates(name)
391 .ok_or_else(|| missing_argument(name))?
392 .block_pos(context.source());
393 let world = context.source().world();
394 if !world.is_full_chunk_loaded_at(position) {
395 return Err(unloaded_position());
396 }
397 if !world.is_in_valid_bounds(position) {
398 return Err(CommandSyntaxError::dynamic(TextComponent::from(
399 &translations::ARGUMENT_POS_OUTOFWORLD,
400 )));
401 }
402 Ok(position)
403}
404
405fn unloaded_position() -> CommandSyntaxError {
406 CommandSyntaxError::dynamic(TextComponent::from(&translations::ARGUMENT_POS_UNLOADED))
407}
408
409fn entity_condition(expected: bool) -> Builder {
410 literal("entity").then(
411 argument("entities", SteelArgumentType::entities())
412 .forks(EXECUTE_ROOT, move |context| {
413 let matches = !context.optional_entities("entities")?.is_empty();
414 Ok(conditional_sources(context.source(), expected, matches))
415 })
416 .executes(move |context| {
417 let count =
418 i32::try_from(context.optional_entities("entities")?.len()).map_err(|_| {
419 CommandSyntaxError::dynamic("Entity count exceeds the command result range")
420 })?;
421 execute_numeric_condition(context, expected, count)
422 }),
423 )
424}
425
426fn loaded_condition(expected: bool) -> Builder {
427 literal("loaded").then(
428 argument("pos", SteelArgumentType::block_pos())
429 .forks(EXECUTE_ROOT, move |context| {
430 let matches = loaded_matches(context)?;
431 Ok(conditional_sources(context.source(), expected, matches))
432 })
433 .executes(move |context| {
434 execute_boolean_condition(context, expected, loaded_matches(context)?)
435 }),
436 )
437}
438
439fn loaded_matches(
440 context: &SteelCommandContext<CommandSource>,
441) -> Result<bool, CommandSyntaxError> {
442 let position = context
443 .coordinates("pos")
444 .ok_or_else(|| missing_argument("pos"))?
445 .block_pos(context.source());
446 Ok(context
447 .source()
448 .world()
449 .is_entity_ticking_chunk_loaded(position))
450}
451
452fn score_condition(expected: bool) -> Builder {
453 literal("score").then(
454 argument("target", SteelArgumentType::score_holder()).then(
455 argument("targetObjective", SteelArgumentType::objective())
456 .then(score_comparison("=", ScoreComparison::Equal, expected))
457 .then(score_comparison("<", ScoreComparison::Less, expected))
458 .then(score_comparison(
459 "<=",
460 ScoreComparison::LessOrEqual,
461 expected,
462 ))
463 .then(score_comparison(">", ScoreComparison::Greater, expected))
464 .then(score_comparison(
465 ">=",
466 ScoreComparison::GreaterOrEqual,
467 expected,
468 ))
469 .then(
470 literal("matches").then(
471 argument("range", SteelArgumentType::int_range())
472 .forks(EXECUTE_ROOT, move |context| {
473 let matches = score_range_matches(context)?;
474 Ok(conditional_sources(context.source(), expected, matches))
475 })
476 .executes(move |context| {
477 execute_boolean_condition(
478 context,
479 expected,
480 score_range_matches(context)?,
481 )
482 }),
483 ),
484 ),
485 ),
486 )
487}
488
489fn score_comparison(name: &'static str, comparison: ScoreComparison, expected: bool) -> Builder {
490 literal(name).then(
491 argument("source", SteelArgumentType::score_holder()).then(
492 argument("sourceObjective", SteelArgumentType::objective())
493 .forks(EXECUTE_ROOT, move |context| {
494 let matches = scores_match(context, comparison)?;
495 Ok(conditional_sources(context.source(), expected, matches))
496 })
497 .executes(move |context| {
498 execute_boolean_condition(context, expected, scores_match(context, comparison)?)
499 }),
500 ),
501 )
502}
503
504fn scores_match(
505 context: &SteelCommandContext<CommandSource>,
506 comparison: ScoreComparison,
507) -> Result<bool, CommandSyntaxError> {
508 let scoreboard = source_scoreboard(context)?;
509 let target = context.score_holder("target")?;
510 let target_objective = objective(context, scoreboard, "targetObjective")?;
511 let source = context.score_holder("source")?;
512 let source_objective = objective(context, scoreboard, "sourceObjective")?;
513 let Some(target_score) = scoreboard.score(&target, &target_objective) else {
514 return Ok(false);
515 };
516 let Some(source_score) = scoreboard.score(&source, &source_objective) else {
517 return Ok(false);
518 };
519 Ok(comparison.matches(target_score, source_score))
520}
521
522fn score_range_matches(
523 context: &SteelCommandContext<CommandSource>,
524) -> Result<bool, CommandSyntaxError> {
525 let scoreboard = source_scoreboard(context)?;
526 let target = context.score_holder("target")?;
527 let target_objective = objective(context, scoreboard, "targetObjective")?;
528 let range = context
529 .int_range("range")
530 .ok_or_else(|| missing_argument("range"))?;
531 Ok(scoreboard
532 .score(&target, &target_objective)
533 .is_some_and(|score| range.matches(score)))
534}
535
536#[derive(Clone, Copy)]
537enum ScoreComparison {
538 Equal,
539 Less,
540 LessOrEqual,
541 Greater,
542 GreaterOrEqual,
543}
544
545impl ScoreComparison {
546 const fn matches(self, target: i32, source: i32) -> bool {
547 match self {
548 Self::Equal => target == source,
549 Self::Less => target < source,
550 Self::LessOrEqual => target <= source,
551 Self::Greater => target > source,
552 Self::GreaterOrEqual => target >= source,
553 }
554 }
555}
556
557fn conditional_sources(
558 source: &CommandSource,
559 expected: bool,
560 matches: bool,
561) -> Vec<CommandSource> {
562 if matches == expected {
563 vec![source.clone()]
564 } else {
565 Vec::new()
566 }
567}
568
569fn execute_boolean_condition(
570 context: &SteelCommandContext<CommandSource>,
571 expected: bool,
572 matches: bool,
573) -> Result<i32, CommandSyntaxError> {
574 if matches != expected {
575 return Err(conditional_failed());
576 }
577 context.source().send_success(
578 &TextComponent::from(&translations::COMMANDS_EXECUTE_CONDITIONAL_PASS),
579 false,
580 );
581 Ok(1)
582}
583
584fn execute_numeric_condition(
585 context: &SteelCommandContext<CommandSource>,
586 expected: bool,
587 count: i32,
588) -> Result<i32, CommandSyntaxError> {
589 if expected {
590 if count == 0 {
591 return Err(conditional_failed());
592 }
593 let message = translations::COMMANDS_EXECUTE_CONDITIONAL_PASS_COUNT
594 .message([TextComponent::from(count.to_string())])
595 .component();
596 context.source().send_success(&message, false);
597 return Ok(count);
598 }
599
600 if count != 0 {
601 return Err(conditional_failed_count(count));
602 }
603 context.source().send_success(
604 &TextComponent::from(&translations::COMMANDS_EXECUTE_CONDITIONAL_PASS),
605 false,
606 );
607 Ok(1)
608}
609
610fn conditional_failed() -> CommandSyntaxError {
611 CommandSyntaxError::dynamic(TextComponent::from(
612 &translations::COMMANDS_EXECUTE_CONDITIONAL_FAIL,
613 ))
614}
615
616fn conditional_failed_count(count: i32) -> CommandSyntaxError {
617 let message = translations::COMMANDS_EXECUTE_CONDITIONAL_FAIL_COUNT
618 .message([TextComponent::from(count.to_string())])
619 .component();
620 CommandSyntaxError::dynamic(message)
621}
622
623fn missing_argument(name: &str) -> CommandSyntaxError {
624 CommandSyntaxError::dynamic(format!(
625 "Parsed value for {name} is missing from the command context"
626 ))
627}
628
629#[cfg(test)]
630mod tests {
631 use std::sync::Weak;
632
633 use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
634 use steel_registry::{init_vanilla_registry, vanilla_block_entity_types, vanilla_blocks};
635 use steel_utils::nbt::parse_nbt_path;
636
637 use super::*;
638 use crate::block_entity::entities::RawBlockEntity;
639
640 fn raw_block_entity(value: i32, pos: BlockPos, reverse_order: bool) -> SharedBlockEntity {
641 let mut data = NbtCompound::new();
642 if reverse_order {
643 data.insert("other", 11_i32);
644 data.insert("value", value);
645 } else {
646 data.insert("value", value);
647 data.insert("other", 11_i32);
648 }
649 data.insert("x", pos.x());
650 Arc::new(RawBlockEntity::with_data(
651 &vanilla_block_entity_types::BARREL,
652 Weak::new(),
653 pos,
654 vanilla_blocks::BARREL.default_state(),
655 data,
656 ))
657 }
658
659 #[test]
660 fn block_region_volume_uses_inclusive_normalized_corners() {
661 let region = BoundingBox::from_corners(BlockPos::new(2, 5, -1), BlockPos::new(-1, 3, 2));
662
663 assert_eq!(block_region_volume(®ion), 48);
664 }
665
666 #[test]
667 fn data_match_count_returns_selected_tag_count() {
668 let path = parse_nbt_path("items[].value").expect("path should parse");
669 let mut first = NbtCompound::new();
670 first.insert("value", 1);
671 let mut second = NbtCompound::new();
672 second.insert("value", 2);
673 let mut root = NbtCompound::new();
674 root.insert("items", NbtList::Compound(vec![first, second]));
675 let tag = NbtTag::Compound(root);
676
677 assert_eq!(
678 matching_data_count(&path, &tag).expect("count should fit"),
679 2
680 );
681 }
682
683 #[test]
684 fn masked_regions_skip_only_vanilla_air() {
685 init_vanilla_registry();
686
687 assert!(!should_compare_block(
688 vanilla_blocks::AIR.default_state(),
689 true
690 ));
691 assert!(should_compare_block(
692 vanilla_blocks::CAVE_AIR.default_state(),
693 true
694 ));
695 assert!(should_compare_block(
696 vanilla_blocks::VOID_AIR.default_state(),
697 true
698 ));
699 assert!(should_compare_block(
700 vanilla_blocks::AIR.default_state(),
701 false
702 ));
703 }
704
705 #[test]
706 fn region_block_entities_compare_type_and_custom_data_only() {
707 init_vanilla_registry();
708 let source = raw_block_entity(7, BlockPos::new(1, 64, 1), false);
709 let matching = raw_block_entity(7, BlockPos::new(4, 70, 4), true);
710 let different = raw_block_entity(8, BlockPos::new(4, 70, 4), false);
711
712 assert!(block_entity_data_matches(Some(&source), Some(&source)));
713 assert!(block_entity_data_matches(Some(&source), Some(&matching)));
714 assert!(!block_entity_data_matches(Some(&source), Some(&different)));
715 assert!(!block_entity_data_matches(Some(&source), None));
716 assert!(block_entity_data_matches(None, Some(&matching)));
717 }
718}