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::{fresh_test_world, insert_ready_full_chunk, insert_unready_full_chunk},
261    };
262
263    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
264
265    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
266        let Some(children) = dispatcher.children(parent) else {
267            panic!("parent node should exist");
268        };
269        let Some(child) = children.iter().copied().find(|child| {
270            dispatcher
271                .node(*child)
272                .is_some_and(|node| node.name() == name)
273        }) else {
274            panic!("child {name} should exist");
275        };
276        child
277    }
278
279    fn assert_executable(dispatcher: &Dispatcher, node: NodeId) {
280        let Some(node) = dispatcher.node(node) else {
281            panic!("command node should exist");
282        };
283        assert!(node.is_executable());
284    }
285
286    fn setup_world(key: &'static str, chunk: ChunkPos) -> Arc<World> {
287        init_vanilla_registry();
288        init_behaviors();
289        init_block_entities();
290        let world = fresh_test_world(key);
291        insert_ready_full_chunk(&world, chunk);
292        world
293    }
294
295    #[test]
296    fn fill_graph_exposes_all_vanilla_modes_and_typed_arguments() {
297        init_vanilla_registry();
298
299        let Ok(dispatcher) = create_dispatcher() else {
300            panic!("built-in commands should register");
301        };
302        let fill = child(&dispatcher, dispatcher.root(), "fill");
303        let from = child(&dispatcher, fill, "from");
304        let to = child(&dispatcher, from, "to");
305        let block = child(&dispatcher, to, "block");
306        assert_eq!(
307            dispatcher.node(block).and_then(|node| node.argument_type()),
308            Some(&SteelArgumentType::block_state())
309        );
310        assert_executable(&dispatcher, block);
311
312        for mode in ["outline", "hollow", "destroy", "strict", "keep"] {
313            let node = child(&dispatcher, block, mode);
314            assert_executable(&dispatcher, node);
315        }
316        let replace = child(&dispatcher, block, "replace");
317        assert_executable(&dispatcher, replace);
318        let filter = child(&dispatcher, replace, "filter");
319        assert_eq!(
320            dispatcher
321                .node(filter)
322                .and_then(|node| node.argument_type()),
323            Some(&SteelArgumentType::block_predicate())
324        );
325        for mode in ["outline", "hollow", "destroy", "strict"] {
326            let node = child(&dispatcher, filter, mode);
327            assert_executable(&dispatcher, node);
328        }
329    }
330
331    #[test]
332    fn hollow_replaces_the_shell_and_clears_the_core() {
333        let origin = BlockPos::new(4, 64, 4);
334        let world = setup_world("fill_hollow", ChunkPos::from_block_pos(origin));
335        let region = BoundingBox::from_corners(origin, origin.offset(2, 2, 2));
336        for z in region.min_z()..=region.max_z() {
337            for y in region.min_y()..=region.max_y() {
338                for x in region.min_x()..=region.max_x() {
339                    assert!(world.set_block(
340                        BlockPos::new(x, y, z),
341                        vanilla_blocks::STONE.default_state(),
342                        UpdateFlags::UPDATE_NONE,
343                    ));
344                }
345            }
346        }
347
348        let target = BlockInput::from_state(vanilla_blocks::GLASS.default_state());
349        let result = fill_blocks(
350            &world,
351            region,
352            &target,
353            FillMode::Hollow,
354            FillSelection::All,
355            false,
356        );
357
358        assert_eq!(result, Ok(27));
359        assert!(world.get_block_state(origin.offset(1, 1, 1)).is_air());
360        assert_eq!(
361            world.get_block_state(origin).get_block(),
362            &vanilla_blocks::GLASS
363        );
364    }
365
366    #[test]
367    fn replace_filter_only_changes_matching_blocks() {
368        let origin = BlockPos::new(5, 64, 5);
369        let world = setup_world("fill_filter", ChunkPos::from_block_pos(origin));
370        let states = [
371            vanilla_blocks::STONE.default_state(),
372            vanilla_blocks::DIRT.default_state(),
373            vanilla_blocks::STONE.default_state(),
374        ];
375        for (offset, state) in states.into_iter().enumerate() {
376            assert!(world.set_block(
377                origin.offset(offset as i32, 0, 0),
378                state,
379                UpdateFlags::UPDATE_NONE,
380            ));
381        }
382        let predicate = BlockPredicate::Block {
383            block: &vanilla_blocks::STONE,
384            properties: Vec::new(),
385            nbt: None,
386        };
387        let target = BlockInput::from_state(vanilla_blocks::GLASS.default_state());
388
389        let result = fill_blocks(
390            &world,
391            BoundingBox::from_corners(origin, origin.offset(2, 0, 0)),
392            &target,
393            FillMode::Replace,
394            FillSelection::Predicate(&predicate),
395            false,
396        );
397
398        assert_eq!(result, Ok(2));
399        assert_eq!(
400            world.get_block_state(origin).get_block(),
401            &vanilla_blocks::GLASS
402        );
403        assert_eq!(
404            world.get_block_state(origin.east()).get_block(),
405            &vanilla_blocks::DIRT
406        );
407        assert_eq!(
408            world.get_block_state(origin.east().east()).get_block(),
409            &vanilla_blocks::GLASS
410        );
411    }
412
413    #[test]
414    fn destroy_mode_counts_the_destroyed_block_and_drops_its_loot() {
415        let pos = BlockPos::new(8, 64, 8);
416        let world = setup_world("fill_destroy", ChunkPos::from_block_pos(pos));
417        assert!(world.set_block(
418            pos,
419            vanilla_blocks::DIRT.default_state(),
420            UpdateFlags::UPDATE_NONE,
421        ));
422        let air = BlockInput::from_state(vanilla_blocks::AIR.default_state());
423
424        assert_eq!(
425            fill_blocks(
426                &world,
427                BoundingBox::from_corners(pos, pos),
428                &air,
429                FillMode::Destroy,
430                FillSelection::All,
431                false,
432            ),
433            Ok(1)
434        );
435
436        assert!(world.get_block_state(pos).is_air());
437        assert!(
438            world
439                .get_entities_in_aabb(&WorldAabb::new(7.0, 63.0, 7.0, 10.0, 67.0, 10.0))
440                .iter()
441                .any(|entity| entity.downcast_ref::<ItemEntity>().is_some())
442        );
443    }
444
445    #[test]
446    fn fill_limit_and_unloaded_region_fail_before_mutation() {
447        let first = BlockPos::new(15, 64, 0);
448        let world = setup_world("fill_preflight", ChunkPos::from_block_pos(first));
449        assert!(world.set_game_rule(&vanilla_game_rules::MAX_BLOCK_MODIFICATIONS, 1));
450        let target = BlockInput::from_state(vanilla_blocks::STONE.default_state());
451        let two_blocks = BoundingBox::from_corners(first, first.east());
452        assert!(
453            fill_blocks(
454                &world,
455                two_blocks,
456                &target,
457                FillMode::Replace,
458                FillSelection::All,
459                false,
460            )
461            .is_err()
462        );
463        assert!(world.get_block_state(first).is_air());
464
465        assert!(world.set_game_rule(&vanilla_game_rules::MAX_BLOCK_MODIFICATIONS, 32_768));
466        assert!(
467            fill_blocks(
468                &world,
469                two_blocks,
470                &target,
471                FillMode::Replace,
472                FillSelection::All,
473                false,
474            )
475            .is_err()
476        );
477        assert!(world.get_block_state(first).is_air());
478    }
479
480    #[test]
481    fn fill_requires_block_ticking_readiness_for_its_neighbor_halo() {
482        let pos = BlockPos::new(15, 64, 8);
483        init_vanilla_registry();
484        init_behaviors();
485        init_block_entities();
486
487        let unavailable = fresh_test_world("fill_unready_halo");
488        insert_unready_full_chunk(&unavailable, ChunkPos::new(0, 0));
489        insert_ready_full_chunk(&unavailable, ChunkPos::new(1, 0));
490        let fence = BlockInput::from_state(vanilla_blocks::OAK_FENCE.default_state());
491        assert!(
492            fill_blocks(
493                &unavailable,
494                BoundingBox::from_corners(pos, pos),
495                &fence,
496                FillMode::Replace,
497                FillSelection::All,
498                false,
499            )
500            .is_err()
501        );
502        assert!(unavailable.get_block_state(pos).is_air());
503
504        let ready = fresh_test_world("fill_ready_halo");
505        insert_ready_full_chunk(&ready, ChunkPos::new(0, 0));
506        insert_ready_full_chunk(&ready, ChunkPos::new(1, 0));
507        assert!(ready.set_block(
508            pos.east(),
509            vanilla_blocks::OAK_FENCE.default_state(),
510            UpdateFlags::UPDATE_NONE,
511        ));
512        assert_eq!(
513            fill_blocks(
514                &ready,
515                BoundingBox::from_corners(pos, pos),
516                &fence,
517                FillMode::Replace,
518                FillSelection::All,
519                false,
520            ),
521            Ok(1)
522        );
523        assert!(
524            steel_registry::REGISTRY
525                .blocks
526                .get_properties(ready.get_block_state(pos))
527                .contains(&("east", "true"))
528        );
529    }
530}