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, command::missing_argument, 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.identifier("source")?;
107            NbtTag::Compound(source_command_storage(context)?.get(key))
108        }
109    };
110    let path = context.nbt_path("path")?;
111    matching_data_count(path, &tag)
112}
113
114fn matching_data_count(path: &NbtPath, tag: &NbtTag) -> Result<i32, CommandSyntaxError> {
115    i32::try_from(path.count_matching(tag))
116        .map_err(|_| CommandSyntaxError::dynamic("NBT match count exceeds the command range"))
117}
118
119pub(super) fn invalid_block_data_source() -> CommandSyntaxError {
120    CommandSyntaxError::dynamic(TextComponent::from(
121        &translations::COMMANDS_DATA_BLOCK_INVALID,
122    ))
123}
124
125fn dimension_condition(expected: bool) -> Builder {
126    literal("dimension").then(
127        argument("dimension", SteelArgumentType::world())
128            .forks(EXECUTE_ROOT, move |context| {
129                let matches = dimension_matches(context)?;
130                Ok(conditional_sources(context.source(), expected, matches))
131            })
132            .executes(move |context| {
133                execute_boolean_condition(context, expected, dimension_matches(context)?)
134            }),
135    )
136}
137
138fn dimension_matches(
139    context: &SteelCommandContext<CommandSource>,
140) -> Result<bool, CommandSyntaxError> {
141    let world = context
142        .world_argument("dimension")?
143        .resolve(context.source())?;
144    Ok(Arc::ptr_eq(context.source().world(), &world))
145}
146
147fn blocks_condition(expected: bool) -> Builder {
148    literal("blocks").then(
149        argument("start", SteelArgumentType::block_pos()).then(
150            argument("end", SteelArgumentType::block_pos()).then(
151                argument("destination", SteelArgumentType::block_pos())
152                    .then(blocks_mode("all", expected, false))
153                    .then(blocks_mode("masked", expected, true)),
154            ),
155        ),
156    )
157}
158
159fn blocks_mode(name: &'static str, expected: bool, skip_air: bool) -> Builder {
160    literal(name)
161        .forks(EXECUTE_ROOT, move |context| {
162            let matches = matching_block_region_count(context, skip_air)?.is_some();
163            Ok(conditional_sources(context.source(), expected, matches))
164        })
165        .executes(move |context| {
166            let count = matching_block_region_count(context, skip_air)?;
167            execute_blocks_condition(context, expected, count)
168        })
169}
170
171fn matching_block_region_count(
172    context: &SteelCommandContext<CommandSource>,
173    skip_air: bool,
174) -> Result<Option<i32>, CommandSyntaxError> {
175    let source_start = loaded_block_position(context, "start")?;
176    let source_end = loaded_block_position(context, "end")?;
177    let destination_start = loaded_block_position(context, "destination")?;
178    let source_region = BoundingBox::from_corners(source_start, source_end);
179    let destination_end = destination_start.offset(
180        source_region.max_x() - source_region.min_x(),
181        source_region.max_y() - source_region.min_y(),
182        source_region.max_z() - source_region.min_z(),
183    );
184    let destination_region = BoundingBox::from_corners(destination_start, destination_end);
185    let area = block_region_volume(&source_region);
186    if area > MAX_BLOCKS_REGION {
187        return Err(blocks_too_big(area));
188    }
189
190    let world = context.source().world();
191    ensure_region_chunks_loaded(world, &source_region)?;
192    ensure_region_chunks_loaded(world, &destination_region)?;
193
194    let offset_x = destination_region.min_x() - source_region.min_x();
195    let offset_y = destination_region.min_y() - source_region.min_y();
196    let offset_z = destination_region.min_z() - source_region.min_z();
197    let mut count = 0;
198    for z in source_region.min_z()..=source_region.max_z() {
199        for y in source_region.min_y()..=source_region.max_y() {
200            for x in source_region.min_x()..=source_region.max_x() {
201                let source_pos = BlockPos::new(x, y, z);
202                let source_state = world.get_block_state(source_pos);
203                if !should_compare_block(source_state, skip_air) {
204                    continue;
205                }
206                let destination_pos = source_pos.offset(offset_x, offset_y, offset_z);
207                if source_state != world.get_block_state(destination_pos)
208                    || !block_entities_match(world, source_pos, destination_pos)
209                {
210                    return Ok(None);
211                }
212                count += 1;
213            }
214        }
215    }
216    Ok(Some(count))
217}
218
219fn block_region_volume(region: &BoundingBox) -> i64 {
220    let x_span = i64::from(region.max_x()) - i64::from(region.min_x()) + 1;
221    let y_span = i64::from(region.max_y()) - i64::from(region.min_y()) + 1;
222    let z_span = i64::from(region.max_z()) - i64::from(region.min_z()) + 1;
223    x_span.saturating_mul(y_span).saturating_mul(z_span)
224}
225
226// Steel's synchronous command runner rejects unloaded region chunks instead of loading them.
227pub(in crate::command::builtins) fn ensure_region_chunks_loaded(
228    world: &World,
229    region: &BoundingBox,
230) -> Result<(), CommandSyntaxError> {
231    ensure_region_chunks_available(world, region, RegionChunkAvailability::Full)
232}
233
234pub(in crate::command::builtins) fn ensure_region_chunks_block_ticking(
235    world: &World,
236    region: &BoundingBox,
237) -> Result<(), CommandSyntaxError> {
238    ensure_region_chunks_available(world, region, RegionChunkAvailability::BlockTicking)
239}
240
241#[derive(Clone, Copy)]
242enum RegionChunkAvailability {
243    Full,
244    BlockTicking,
245}
246
247fn ensure_region_chunks_available(
248    world: &World,
249    region: &BoundingBox,
250    availability: RegionChunkAvailability,
251) -> Result<(), CommandSyntaxError> {
252    if region.max_y() < world.get_min_y() || region.min_y() > world.get_max_y() {
253        return Ok(());
254    }
255    let min_chunk_x = SectionPos::block_to_section_coord(region.min_x());
256    let max_chunk_x = SectionPos::block_to_section_coord(region.max_x());
257    let min_chunk_z = SectionPos::block_to_section_coord(region.min_z());
258    let max_chunk_z = SectionPos::block_to_section_coord(region.max_z());
259    for chunk_z in min_chunk_z..=max_chunk_z {
260        for chunk_x in min_chunk_x..=max_chunk_x {
261            if !ChunkPos::is_valid(chunk_x, chunk_z) {
262                continue;
263            }
264            let pos = BlockPos::new(chunk_x * 16, world.get_min_y(), chunk_z * 16);
265            let available = match availability {
266                RegionChunkAvailability::Full => world.is_full_chunk_loaded_at(pos),
267                RegionChunkAvailability::BlockTicking => world.is_block_ticking_chunk_loaded(pos),
268            };
269            if !available {
270                return Err(unloaded_position());
271            }
272        }
273    }
274    Ok(())
275}
276
277fn should_compare_block(state: steel_utils::BlockStateId, skip_air: bool) -> bool {
278    !skip_air || state.get_block() != &vanilla_blocks::AIR
279}
280
281fn block_entities_match(world: &World, source: BlockPos, destination: BlockPos) -> bool {
282    let source_entity = world.get_block_entity(source);
283    let destination_entity = world.get_block_entity(destination);
284    block_entity_data_matches(source_entity.as_ref(), destination_entity.as_ref())
285}
286
287fn block_entity_data_matches(
288    source: Option<&SharedBlockEntity>,
289    destination: Option<&SharedBlockEntity>,
290) -> bool {
291    let Some(source) = source else {
292        return true;
293    };
294    let Some(destination) = destination else {
295        return false;
296    };
297    if Arc::ptr_eq(source, destination) {
298        return true;
299    }
300    if source.get_type() != destination.get_type() {
301        return false;
302    }
303    let source_data = source.save_custom_only();
304    let destination_data = destination.save_custom_only();
305    source_data.len() == destination_data.len()
306        && compare_nbt_compounds(&source_data, &destination_data, false)
307}
308
309fn execute_blocks_condition(
310    context: &SteelCommandContext<CommandSource>,
311    expected: bool,
312    count: Option<i32>,
313) -> Result<i32, CommandSyntaxError> {
314    match (expected, count) {
315        (true, Some(count)) => {
316            let message = translations::COMMANDS_EXECUTE_CONDITIONAL_PASS_COUNT
317                .message([TextComponent::from(count.to_string())])
318                .component();
319            context.source().send_success(&message, false);
320            Ok(count)
321        }
322        (true, None) => Err(conditional_failed()),
323        (false, Some(count)) => Err(conditional_failed_count(count)),
324        (false, None) => {
325            context.source().send_success(
326                &TextComponent::from(&translations::COMMANDS_EXECUTE_CONDITIONAL_PASS),
327                false,
328            );
329            Ok(1)
330        }
331    }
332}
333
334fn blocks_too_big(area: i64) -> CommandSyntaxError {
335    let message = translations::COMMANDS_EXECUTE_BLOCKS_TOOBIG
336        .message([
337            TextComponent::from(MAX_BLOCKS_REGION.to_string()),
338            TextComponent::from(area.to_string()),
339        ])
340        .component();
341    CommandSyntaxError::dynamic(message)
342}
343
344fn block_condition(expected: bool) -> Builder {
345    literal("block").then(
346        argument("pos", SteelArgumentType::block_pos()).then(
347            argument("block", SteelArgumentType::block_predicate())
348                .forks(EXECUTE_ROOT, move |context| {
349                    let matches = block_matches(context)?;
350                    Ok(conditional_sources(context.source(), expected, matches))
351                })
352                .executes(move |context| {
353                    execute_boolean_condition(context, expected, block_matches(context)?)
354                }),
355        ),
356    )
357}
358
359fn block_matches(context: &SteelCommandContext<CommandSource>) -> Result<bool, CommandSyntaxError> {
360    let position = loaded_block_position(context, "pos")?;
361    let Ok(predicate) = context.block_predicate("block") else {
362        return Err(missing_argument("block"));
363    };
364    let world = context.source().world();
365    Ok(predicate.matches(world, position))
366}
367
368fn biome_condition(expected: bool) -> Builder {
369    literal("biome").then(
370        argument("pos", SteelArgumentType::block_pos()).then(
371            argument("biome", SteelArgumentType::biome_or_tag())
372                .forks(EXECUTE_ROOT, move |context| {
373                    let matches = biome_matches(context)?;
374                    Ok(conditional_sources(context.source(), expected, matches))
375                })
376                .executes(move |context| {
377                    execute_boolean_condition(context, expected, biome_matches(context)?)
378                }),
379        ),
380    )
381}
382
383fn biome_matches(context: &SteelCommandContext<CommandSource>) -> Result<bool, CommandSyntaxError> {
384    let position = loaded_block_position(context, "pos")?;
385    let world = context.source().world();
386    let biome = world.biome_at(position).ok_or_else(|| {
387        CommandSyntaxError::dynamic(TextComponent::from(&translations::ARGUMENT_POS_UNLOADED))
388    })?;
389    let expected = context.biome_or_tag("biome")?;
390    Ok(expected.matches(biome))
391}
392
393pub fn loaded_block_position(
394    context: &SteelCommandContext<CommandSource>,
395    name: &str,
396) -> Result<steel_utils::BlockPos, CommandSyntaxError> {
397    let position = context.coordinates(name)?.block_pos(context.source());
398    let world = context.source().world();
399    if !world.is_full_chunk_loaded_at(position) {
400        return Err(unloaded_position());
401    }
402    if !world.is_in_valid_bounds(position) {
403        return Err(CommandSyntaxError::dynamic(TextComponent::from(
404            &translations::ARGUMENT_POS_OUTOFWORLD,
405        )));
406    }
407    Ok(position)
408}
409
410fn unloaded_position() -> CommandSyntaxError {
411    CommandSyntaxError::dynamic(TextComponent::from(&translations::ARGUMENT_POS_UNLOADED))
412}
413
414fn entity_condition(expected: bool) -> Builder {
415    literal("entity").then(
416        argument("entities", SteelArgumentType::entities())
417            .forks(EXECUTE_ROOT, move |context| {
418                let matches = !context.optional_entities("entities")?.is_empty();
419                Ok(conditional_sources(context.source(), expected, matches))
420            })
421            .executes(move |context| {
422                let count =
423                    i32::try_from(context.optional_entities("entities")?.len()).map_err(|_| {
424                        CommandSyntaxError::dynamic("Entity count exceeds the command result range")
425                    })?;
426                execute_numeric_condition(context, expected, count)
427            }),
428    )
429}
430
431fn loaded_condition(expected: bool) -> Builder {
432    literal("loaded").then(
433        argument("pos", SteelArgumentType::block_pos())
434            .forks(EXECUTE_ROOT, move |context| {
435                let matches = loaded_matches(context)?;
436                Ok(conditional_sources(context.source(), expected, matches))
437            })
438            .executes(move |context| {
439                execute_boolean_condition(context, expected, loaded_matches(context)?)
440            }),
441    )
442}
443
444fn loaded_matches(
445    context: &SteelCommandContext<CommandSource>,
446) -> Result<bool, CommandSyntaxError> {
447    let position = context.coordinates("pos")?.block_pos(context.source());
448    Ok(context
449        .source()
450        .world()
451        .is_entity_ticking_chunk_loaded(position))
452}
453
454fn score_condition(expected: bool) -> Builder {
455    literal("score").then(
456        argument("target", SteelArgumentType::score_holder()).then(
457            argument("targetObjective", SteelArgumentType::objective())
458                .then(score_comparison("=", ScoreComparison::Equal, expected))
459                .then(score_comparison("<", ScoreComparison::Less, expected))
460                .then(score_comparison(
461                    "<=",
462                    ScoreComparison::LessOrEqual,
463                    expected,
464                ))
465                .then(score_comparison(">", ScoreComparison::Greater, expected))
466                .then(score_comparison(
467                    ">=",
468                    ScoreComparison::GreaterOrEqual,
469                    expected,
470                ))
471                .then(
472                    literal("matches").then(
473                        argument("range", SteelArgumentType::int_range())
474                            .forks(EXECUTE_ROOT, move |context| {
475                                let matches = score_range_matches(context)?;
476                                Ok(conditional_sources(context.source(), expected, matches))
477                            })
478                            .executes(move |context| {
479                                execute_boolean_condition(
480                                    context,
481                                    expected,
482                                    score_range_matches(context)?,
483                                )
484                            }),
485                    ),
486                ),
487        ),
488    )
489}
490
491fn score_comparison(name: &'static str, comparison: ScoreComparison, expected: bool) -> Builder {
492    literal(name).then(
493        argument("source", SteelArgumentType::score_holder()).then(
494            argument("sourceObjective", SteelArgumentType::objective())
495                .forks(EXECUTE_ROOT, move |context| {
496                    let matches = scores_match(context, comparison)?;
497                    Ok(conditional_sources(context.source(), expected, matches))
498                })
499                .executes(move |context| {
500                    execute_boolean_condition(context, expected, scores_match(context, comparison)?)
501                }),
502        ),
503    )
504}
505
506fn scores_match(
507    context: &SteelCommandContext<CommandSource>,
508    comparison: ScoreComparison,
509) -> Result<bool, CommandSyntaxError> {
510    let scoreboard = source_scoreboard(context)?;
511    let target = context.score_holder("target")?;
512    let target_objective = objective(context, scoreboard, "targetObjective")?;
513    let source = context.score_holder("source")?;
514    let source_objective = objective(context, scoreboard, "sourceObjective")?;
515    let Some(target_score) = scoreboard.score(&target, &target_objective) else {
516        return Ok(false);
517    };
518    let Some(source_score) = scoreboard.score(&source, &source_objective) else {
519        return Ok(false);
520    };
521    Ok(comparison.matches(target_score, source_score))
522}
523
524fn score_range_matches(
525    context: &SteelCommandContext<CommandSource>,
526) -> Result<bool, CommandSyntaxError> {
527    let scoreboard = source_scoreboard(context)?;
528    let target = context.score_holder("target")?;
529    let target_objective = objective(context, scoreboard, "targetObjective")?;
530    let range = context.int_range("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
623#[cfg(test)]
624mod tests {
625    use std::sync::Weak;
626
627    use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
628    use steel_registry::{init_vanilla_registry, vanilla_block_entity_types, vanilla_blocks};
629    use steel_utils::nbt::parse_nbt_path;
630
631    use super::*;
632    use crate::block_entity::entities::UnimplementedBlockEntity;
633
634    fn unimplemented_block_entity(
635        value: i32,
636        pos: BlockPos,
637        reverse_order: bool,
638    ) -> SharedBlockEntity {
639        let mut data = NbtCompound::new();
640        if reverse_order {
641            data.insert("other", 11_i32);
642            data.insert("value", value);
643        } else {
644            data.insert("value", value);
645            data.insert("other", 11_i32);
646        }
647        data.insert("x", pos.x());
648        Arc::new(UnimplementedBlockEntity::with_data(
649            &vanilla_block_entity_types::BARREL,
650            Weak::new(),
651            pos,
652            vanilla_blocks::BARREL.default_state(),
653            data,
654        ))
655    }
656
657    #[test]
658    fn block_region_volume_uses_inclusive_normalized_corners() {
659        let region = BoundingBox::from_corners(BlockPos::new(2, 5, -1), BlockPos::new(-1, 3, 2));
660
661        assert_eq!(block_region_volume(&region), 48);
662    }
663
664    #[test]
665    fn data_match_count_returns_selected_tag_count() {
666        let path = parse_nbt_path("items[].value").expect("path should parse");
667        let mut first = NbtCompound::new();
668        first.insert("value", 1);
669        let mut second = NbtCompound::new();
670        second.insert("value", 2);
671        let mut root = NbtCompound::new();
672        root.insert("items", NbtList::Compound(vec![first, second]));
673        let tag = NbtTag::Compound(root);
674
675        assert_eq!(
676            matching_data_count(&path, &tag).expect("count should fit"),
677            2
678        );
679    }
680
681    #[test]
682    fn masked_regions_skip_only_vanilla_air() {
683        init_vanilla_registry();
684
685        assert!(!should_compare_block(
686            vanilla_blocks::AIR.default_state(),
687            true
688        ));
689        assert!(should_compare_block(
690            vanilla_blocks::CAVE_AIR.default_state(),
691            true
692        ));
693        assert!(should_compare_block(
694            vanilla_blocks::VOID_AIR.default_state(),
695            true
696        ));
697        assert!(should_compare_block(
698            vanilla_blocks::AIR.default_state(),
699            false
700        ));
701    }
702
703    #[test]
704    fn region_block_entities_compare_type_and_custom_data_only() {
705        init_vanilla_registry();
706        let source = unimplemented_block_entity(7, BlockPos::new(1, 64, 1), false);
707        let matching = unimplemented_block_entity(7, BlockPos::new(4, 70, 4), true);
708        let different = unimplemented_block_entity(8, BlockPos::new(4, 70, 4), false);
709
710        assert!(block_entity_data_matches(Some(&source), Some(&source)));
711        assert!(block_entity_data_matches(Some(&source), Some(&matching)));
712        assert!(!block_entity_data_matches(Some(&source), Some(&different)));
713        assert!(!block_entity_data_matches(Some(&source), None));
714        assert!(block_entity_data_matches(None, Some(&matching)));
715    }
716}