Skip to main content

steel_core/command/builtins/
fill.rs

1//! Vanilla block-region fill command.
2
3use std::sync::Arc;
4
5use steel_registry::{
6    blocks::block_state_ext::BlockStateExt as _, vanilla_blocks,
7    vanilla_game_rules::MAX_BLOCK_MODIFICATIONS,
8};
9use steel_utils::{BlockPos, BoundingBox, Identifier, translations};
10use text_components::TextComponent;
11
12use super::super::{
13    brigadier::{CommandNodeBuilder, CommandSyntaxError},
14    execution::{
15        BlockInput, BlockPredicate, CommandSource, SteelArgumentType, SteelCommandContext,
16        SteelCommandRuntime, argument, literal, placement_flags,
17    },
18    registration::CommandRegistration,
19};
20use super::execute::{ensure_region_chunks_block_ticking, loaded_block_position};
21use crate::world::World;
22
23type Builder = CommandNodeBuilder<CommandSource, SteelCommandRuntime>;
24
25#[derive(Clone, Copy)]
26enum FillMode {
27    Replace,
28    Outline,
29    Hollow,
30    Destroy,
31}
32
33#[derive(Clone, Copy)]
34enum FilterArgument {
35    All,
36    Keep,
37    Predicate(&'static str),
38}
39
40enum FillSelection<'predicate> {
41    All,
42    Keep,
43    Predicate(&'predicate BlockPredicate),
44}
45
46impl FillSelection<'_> {
47    fn matches(&self, world: &World, pos: BlockPos) -> bool {
48        match self {
49            Self::All => true,
50            Self::Keep => world.get_block_state(pos).is_air(),
51            Self::Predicate(predicate) => predicate.matches(world, pos),
52        }
53    }
54}
55
56pub(super) fn registration() -> CommandRegistration<CommandSource> {
57    CommandRegistration::new(Identifier::vanilla_static("fill"), |_| command())
58}
59
60fn command() -> Builder {
61    let block =
62        with_modes(
63            argument("block", SteelArgumentType::block_state()),
64            FilterArgument::All,
65        )
66        .then(
67            literal("replace")
68                .executes(|context| {
69                    execute_fill(context, FillMode::Replace, FilterArgument::All, false)
70                })
71                .then(with_modes(
72                    argument("filter", SteelArgumentType::block_predicate()),
73                    FilterArgument::Predicate("filter"),
74                )),
75        )
76        .then(literal("keep").executes(|context| {
77            execute_fill(context, FillMode::Replace, FilterArgument::Keep, false)
78        }));
79
80    literal("fill").then(
81        argument("from", SteelArgumentType::block_pos())
82            .then(argument("to", SteelArgumentType::block_pos()).then(block)),
83    )
84}
85
86fn with_modes(builder: Builder, filter: FilterArgument) -> Builder {
87    builder
88        .executes(move |context| execute_fill(context, FillMode::Replace, filter, false))
89        .then(
90            literal("outline")
91                .executes(move |context| execute_fill(context, FillMode::Outline, filter, false)),
92        )
93        .then(
94            literal("hollow")
95                .executes(move |context| execute_fill(context, FillMode::Hollow, filter, false)),
96        )
97        .then(
98            literal("destroy")
99                .executes(move |context| execute_fill(context, FillMode::Destroy, filter, false)),
100        )
101        .then(
102            literal("strict")
103                .executes(move |context| execute_fill(context, FillMode::Replace, filter, true)),
104        )
105}
106
107fn execute_fill(
108    context: &SteelCommandContext<CommandSource>,
109    mode: FillMode,
110    filter: FilterArgument,
111    strict: bool,
112) -> Result<i32, CommandSyntaxError> {
113    let from = loaded_block_position(context, "from")?;
114    let to = loaded_block_position(context, "to")?;
115    let target = context.block_input("block")?;
116    let selection = match filter {
117        FilterArgument::All => FillSelection::All,
118        FilterArgument::Keep => FillSelection::Keep,
119        FilterArgument::Predicate(name) => FillSelection::Predicate(context.block_predicate(name)?),
120    };
121    let count = fill_blocks(
122        context.source().world(),
123        BoundingBox::from_corners(from, to),
124        target,
125        mode,
126        selection,
127        strict,
128    )?;
129
130    let message = translations::COMMANDS_FILL_SUCCESS
131        .message([TextComponent::from(count.to_string())])
132        .component();
133    context.source().send_success(&message, true);
134    Ok(count)
135}
136
137fn fill_blocks(
138    world: &Arc<World>,
139    region: BoundingBox,
140    target: &BlockInput,
141    mode: FillMode,
142    selection: FillSelection<'_>,
143    strict: bool,
144) -> Result<i32, CommandSyntaxError> {
145    let area = block_region_volume(region);
146    let limit = world.get_game_rule(&MAX_BLOCK_MODIFICATIONS);
147    if area > i64::from(limit) {
148        return Err(area_too_large(limit, area));
149    }
150    // Block-ticking readiness guarantees the radius-one Full-chunk halo needed by
151    // direct shape and neighbor access. Callbacks that propagate farther retain
152    // Steel's normal loaded-world boundary behavior instead of acquiring chunks.
153    ensure_region_chunks_block_ticking(world, &region)?;
154
155    let air = BlockInput::from_state(vanilla_blocks::AIR.default_state());
156    let flags = placement_flags(strict);
157    let mut updated_positions = Vec::new();
158    let mut count = 0;
159
160    for z in region.min_z()..=region.max_z() {
161        for y in region.min_y()..=region.max_y() {
162            for x in region.min_x()..=region.max_x() {
163                let pos = BlockPos::new(x, y, z);
164                if !selection.matches(world, pos) {
165                    continue;
166                }
167
168                let old_state = world.get_block_state(pos);
169                let affected = matches!(mode, FillMode::Destroy) && world.destroy_block(pos, true);
170                let input = input_at(mode, region, pos, target, &air);
171                let Some(input) = input else {
172                    if affected {
173                        count += 1;
174                    }
175                    continue;
176                };
177
178                let placed = input.place(world, pos, flags)?;
179                if !placed {
180                    if affected {
181                        count += 1;
182                    }
183                    continue;
184                }
185
186                if !strict {
187                    updated_positions.push((pos, old_state));
188                }
189                count += 1;
190            }
191        }
192    }
193
194    for (pos, old_state) in updated_positions {
195        world.update_neighbors_on_block_set(pos, old_state);
196    }
197
198    if count == 0 {
199        return Err(CommandSyntaxError::dynamic(TextComponent::from(
200            &translations::COMMANDS_FILL_FAILED,
201        )));
202    }
203    Ok(count)
204}
205
206const fn input_at<'input>(
207    mode: FillMode,
208    region: BoundingBox,
209    pos: BlockPos,
210    target: &'input BlockInput,
211    air: &'input BlockInput,
212) -> Option<&'input BlockInput> {
213    let boundary = pos.x() == region.min_x()
214        || pos.x() == region.max_x()
215        || pos.y() == region.min_y()
216        || pos.y() == region.max_y()
217        || pos.z() == region.min_z()
218        || pos.z() == region.max_z();
219    match mode {
220        FillMode::Outline if !boundary => None,
221        FillMode::Hollow if !boundary => Some(air),
222        FillMode::Replace | FillMode::Outline | FillMode::Hollow | FillMode::Destroy => {
223            Some(target)
224        }
225    }
226}
227
228fn block_region_volume(region: BoundingBox) -> i64 {
229    let x_span = i64::from(region.max_x()) - i64::from(region.min_x()) + 1;
230    let y_span = i64::from(region.max_y()) - i64::from(region.min_y()) + 1;
231    let z_span = i64::from(region.max_z()) - i64::from(region.min_z()) + 1;
232    x_span.saturating_mul(y_span).saturating_mul(z_span)
233}
234
235fn area_too_large(limit: i32, area: i64) -> CommandSyntaxError {
236    let message = translations::COMMANDS_FILL_TOOBIG
237        .message([
238            TextComponent::from(limit.to_string()),
239            TextComponent::from(area.to_string()),
240        ])
241        .component();
242    CommandSyntaxError::dynamic(message)
243}
244
245#[cfg(test)]
246mod tests {
247    use steel_registry::{init_vanilla_registry, vanilla_game_rules};
248    use steel_utils::{ChunkPos, Downcast as _, WorldAabb, types::UpdateFlags};
249
250    use super::super::create_dispatcher;
251    use super::*;
252    use crate::{
253        behavior::init_behaviors,
254        block_entity::init_block_entities,
255        command::{
256            brigadier::{CommandDispatcher, NodeId},
257            execution::{SteelArgumentType, SteelCommandRuntime},
258        },
259        entity::entities::ItemEntity,
260        test_support::{
261            TestWorld, fresh_test_world, insert_ready_full_chunk, insert_unready_full_chunk,
262        },
263    };
264
265    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
266
267    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
268        let Some(children) = dispatcher.children(parent) else {
269            panic!("parent node should exist");
270        };
271        let Some(child) = children.iter().copied().find(|child| {
272            dispatcher
273                .node(*child)
274                .is_some_and(|node| node.name() == name)
275        }) else {
276            panic!("child {name} should exist");
277        };
278        child
279    }
280
281    fn assert_executable(dispatcher: &Dispatcher, node: NodeId) {
282        let Some(node) = dispatcher.node(node) else {
283            panic!("command node should exist");
284        };
285        assert!(node.is_executable());
286    }
287
288    fn setup_world(key: &'static str, chunk: ChunkPos) -> TestWorld {
289        init_vanilla_registry();
290        init_behaviors();
291        init_block_entities();
292        let world_fixture = fresh_test_world(key);
293        let world = &world_fixture.world;
294        insert_ready_full_chunk(world, chunk);
295        world_fixture
296    }
297
298    #[test]
299    fn fill_graph_exposes_all_vanilla_modes_and_typed_arguments() {
300        init_vanilla_registry();
301
302        let Ok(dispatcher) = create_dispatcher() else {
303            panic!("built-in commands should register");
304        };
305        let fill = child(&dispatcher, dispatcher.root(), "fill");
306        let from = child(&dispatcher, fill, "from");
307        let to = child(&dispatcher, from, "to");
308        let block = child(&dispatcher, to, "block");
309        assert_eq!(
310            dispatcher.node(block).and_then(|node| node.argument_type()),
311            Some(&SteelArgumentType::block_state())
312        );
313        assert_executable(&dispatcher, block);
314
315        for mode in ["outline", "hollow", "destroy", "strict", "keep"] {
316            let node = child(&dispatcher, block, mode);
317            assert_executable(&dispatcher, node);
318        }
319        let replace = child(&dispatcher, block, "replace");
320        assert_executable(&dispatcher, replace);
321        let filter = child(&dispatcher, replace, "filter");
322        assert_eq!(
323            dispatcher
324                .node(filter)
325                .and_then(|node| node.argument_type()),
326            Some(&SteelArgumentType::block_predicate())
327        );
328        for mode in ["outline", "hollow", "destroy", "strict"] {
329            let node = child(&dispatcher, filter, mode);
330            assert_executable(&dispatcher, node);
331        }
332    }
333
334    #[test]
335    fn hollow_replaces_the_shell_and_clears_the_core() {
336        let origin = BlockPos::new(4, 64, 4);
337        let world_fixture = setup_world("fill_hollow", ChunkPos::from_block_pos(origin));
338        let world = &world_fixture.world;
339        let region = BoundingBox::from_corners(origin, origin.offset(2, 2, 2));
340        for z in region.min_z()..=region.max_z() {
341            for y in region.min_y()..=region.max_y() {
342                for x in region.min_x()..=region.max_x() {
343                    assert!(world.set_block(
344                        BlockPos::new(x, y, z),
345                        vanilla_blocks::STONE.default_state(),
346                        UpdateFlags::UPDATE_NONE,
347                    ));
348                }
349            }
350        }
351
352        let target = BlockInput::from_state(vanilla_blocks::GLASS.default_state());
353        let result = fill_blocks(
354            world,
355            region,
356            &target,
357            FillMode::Hollow,
358            FillSelection::All,
359            false,
360        );
361
362        assert_eq!(result, Ok(27));
363        assert!(world.get_block_state(origin.offset(1, 1, 1)).is_air());
364        assert_eq!(
365            world.get_block_state(origin).get_block(),
366            &vanilla_blocks::GLASS
367        );
368    }
369
370    #[test]
371    fn replace_filter_only_changes_matching_blocks() {
372        let origin = BlockPos::new(5, 64, 5);
373        let world_fixture = setup_world("fill_filter", ChunkPos::from_block_pos(origin));
374        let world = &world_fixture.world;
375        let states = [
376            vanilla_blocks::STONE.default_state(),
377            vanilla_blocks::DIRT.default_state(),
378            vanilla_blocks::STONE.default_state(),
379        ];
380        for (offset, state) in states.into_iter().enumerate() {
381            assert!(world.set_block(
382                origin.offset(offset as i32, 0, 0),
383                state,
384                UpdateFlags::UPDATE_NONE,
385            ));
386        }
387        let predicate = BlockPredicate::Block {
388            block: &vanilla_blocks::STONE,
389            properties: Vec::new(),
390            nbt: None,
391        };
392        let target = BlockInput::from_state(vanilla_blocks::GLASS.default_state());
393
394        let result = fill_blocks(
395            world,
396            BoundingBox::from_corners(origin, origin.offset(2, 0, 0)),
397            &target,
398            FillMode::Replace,
399            FillSelection::Predicate(&predicate),
400            false,
401        );
402
403        assert_eq!(result, Ok(2));
404        assert_eq!(
405            world.get_block_state(origin).get_block(),
406            &vanilla_blocks::GLASS
407        );
408        assert_eq!(
409            world.get_block_state(origin.east()).get_block(),
410            &vanilla_blocks::DIRT
411        );
412        assert_eq!(
413            world.get_block_state(origin.east().east()).get_block(),
414            &vanilla_blocks::GLASS
415        );
416    }
417
418    #[test]
419    fn destroy_mode_counts_the_destroyed_block_and_drops_its_loot() {
420        let pos = BlockPos::new(8, 64, 8);
421        let world_fixture = setup_world("fill_destroy", ChunkPos::from_block_pos(pos));
422        let world = &world_fixture.world;
423        assert!(world.set_block(
424            pos,
425            vanilla_blocks::DIRT.default_state(),
426            UpdateFlags::UPDATE_NONE,
427        ));
428        let air = BlockInput::from_state(vanilla_blocks::AIR.default_state());
429
430        assert_eq!(
431            fill_blocks(
432                world,
433                BoundingBox::from_corners(pos, pos),
434                &air,
435                FillMode::Destroy,
436                FillSelection::All,
437                false,
438            ),
439            Ok(1)
440        );
441
442        assert!(world.get_block_state(pos).is_air());
443        assert!(
444            world
445                .get_entities_in_aabb(&WorldAabb::new(7.0, 63.0, 7.0, 10.0, 67.0, 10.0))
446                .iter()
447                .any(|entity| entity.downcast_ref::<ItemEntity>().is_some())
448        );
449    }
450
451    #[test]
452    fn fill_limit_and_unloaded_region_fail_before_mutation() {
453        let first = BlockPos::new(15, 64, 0);
454        let world_fixture = setup_world("fill_preflight", ChunkPos::from_block_pos(first));
455        let world = &world_fixture.world;
456        assert!(world.set_game_rule(&vanilla_game_rules::MAX_BLOCK_MODIFICATIONS, 1));
457        let target = BlockInput::from_state(vanilla_blocks::STONE.default_state());
458        let two_blocks = BoundingBox::from_corners(first, first.east());
459        assert!(
460            fill_blocks(
461                world,
462                two_blocks,
463                &target,
464                FillMode::Replace,
465                FillSelection::All,
466                false,
467            )
468            .is_err()
469        );
470        assert!(world.get_block_state(first).is_air());
471
472        assert!(world.set_game_rule(&vanilla_game_rules::MAX_BLOCK_MODIFICATIONS, 32_768));
473        assert!(
474            fill_blocks(
475                world,
476                two_blocks,
477                &target,
478                FillMode::Replace,
479                FillSelection::All,
480                false,
481            )
482            .is_err()
483        );
484        assert!(world.get_block_state(first).is_air());
485    }
486
487    #[test]
488    fn fill_requires_block_ticking_readiness_for_its_neighbor_halo() {
489        let pos = BlockPos::new(15, 64, 8);
490        init_vanilla_registry();
491        init_behaviors();
492        init_block_entities();
493
494        let unavailable_fixture = fresh_test_world("fill_unready_halo");
495        let unavailable = &unavailable_fixture.world;
496        insert_unready_full_chunk(unavailable, ChunkPos::new(0, 0));
497        insert_ready_full_chunk(unavailable, ChunkPos::new(1, 0));
498        let fence = BlockInput::from_state(vanilla_blocks::OAK_FENCE.default_state());
499        assert!(
500            fill_blocks(
501                unavailable,
502                BoundingBox::from_corners(pos, pos),
503                &fence,
504                FillMode::Replace,
505                FillSelection::All,
506                false,
507            )
508            .is_err()
509        );
510        assert!(unavailable.get_block_state(pos).is_air());
511
512        let ready_fixture = fresh_test_world("fill_ready_halo");
513        let ready = &ready_fixture.world;
514        insert_ready_full_chunk(ready, ChunkPos::new(0, 0));
515        insert_ready_full_chunk(ready, ChunkPos::new(1, 0));
516        assert!(ready.set_block(
517            pos.east(),
518            vanilla_blocks::OAK_FENCE.default_state(),
519            UpdateFlags::UPDATE_NONE,
520        ));
521        assert_eq!(
522            fill_blocks(
523                ready,
524                BoundingBox::from_corners(pos, pos),
525                &fence,
526                FillMode::Replace,
527                FillSelection::All,
528                false,
529            ),
530            Ok(1)
531        );
532        assert!(
533            steel_registry::REGISTRY
534                .blocks
535                .properties(ready.get_block_state(pos))
536                .any(|property| property == ("east", "true"))
537        );
538    }
539}