1use 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 ensure_region_chunks_block_ticking(world, ®ion)?;
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}