Skip to main content

steel_core/command/builtins/execute/
condition.rs

1//! `/execute if` and `/execute unless` conditions.
2
3use 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    // TODO: Add items after every vanilla command-slot provider and container inventory is
31    // modeled, including deferred loot-table unpacking.
32    // TODO: Add predicate and function after their runtime registries are ported.
33    // TODO: Restore Steel stopwatch conditions with the stopwatch command system.
34    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
231// Steel's synchronous command runner rejects unloaded region chunks instead of loading them.
232fn 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(&region), 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}