azalea_client/plugins/
mining.rs

1use azalea_block::{BlockState, BlockTrait, fluid_state::FluidState};
2use azalea_core::{direction::Direction, game_type::GameMode, position::BlockPos, tick::GameTick};
3use azalea_entity::{
4    ActiveEffects, FluidOnEyes, Physics, PlayerAbilities, Position, mining::get_mine_progress,
5};
6use azalea_inventory::ItemStack;
7use azalea_physics::{PhysicsSystems, collision::BlockWithShape};
8use azalea_protocol::packets::game::s_player_action::{self, ServerboundPlayerAction};
9use azalea_world::{InstanceContainer, InstanceName};
10use bevy_app::{App, Plugin, Update};
11use bevy_ecs::prelude::*;
12use derive_more::{Deref, DerefMut};
13use tracing::{debug, trace, warn};
14
15use crate::{
16    Client,
17    interact::{
18        BlockStatePredictionHandler, SwingArmEvent, can_use_game_master_blocks,
19        check_is_interaction_restricted, pick::HitResultComponent,
20    },
21    inventory::{Inventory, InventorySystems},
22    local_player::{InstanceHolder, LocalGameMode, PermissionLevel},
23    movement::MoveEventsSystems,
24    packet::game::SendGamePacketEvent,
25};
26
27/// A plugin that allows clients to break blocks in the world.
28pub struct MiningPlugin;
29impl Plugin for MiningPlugin {
30    fn build(&self, app: &mut App) {
31        app.add_message::<StartMiningBlockEvent>()
32            .add_message::<StopMiningBlockEvent>()
33            .add_message::<MineBlockProgressEvent>()
34            .add_message::<AttackBlockEvent>()
35            .add_systems(
36                GameTick,
37                (
38                    update_mining_component,
39                    handle_auto_mine,
40                    handle_mining_queued,
41                    continue_mining_block,
42                )
43                    .chain()
44                    .before(PhysicsSystems)
45                    .before(super::movement::send_position)
46                    .before(super::interact::handle_start_use_item_queued)
47                    .in_set(MiningSystems),
48            )
49            .add_systems(
50                Update,
51                (
52                    handle_start_mining_block_event,
53                    handle_stop_mining_block_event,
54                )
55                    .chain()
56                    .in_set(MiningSystems)
57                    .after(InventorySystems)
58                    .after(MoveEventsSystems)
59                    .after(azalea_entity::update_fluid_on_eyes)
60                    .after(crate::interact::pick::update_hit_result_component)
61                    .after(crate::attack::handle_attack_event),
62            )
63            .add_observer(handle_finish_mining_block_observer);
64    }
65}
66
67/// The Bevy system set for things related to mining.
68#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
69pub struct MiningSystems;
70
71impl Client {
72    pub fn start_mining(&self, position: BlockPos) {
73        let mut ecs = self.ecs.lock();
74
75        ecs.write_message(StartMiningBlockEvent {
76            entity: self.entity,
77            position,
78            force: true,
79        });
80    }
81
82    /// When enabled, the bot will mine any block that it is looking at if it is
83    /// reachable.
84    pub fn left_click_mine(&self, enabled: bool) {
85        let mut ecs = self.ecs.lock();
86        let mut entity_mut = ecs.entity_mut(self.entity);
87
88        if enabled {
89            entity_mut.insert(LeftClickMine);
90        } else {
91            entity_mut.remove::<LeftClickMine>();
92        }
93    }
94}
95
96/// A component that simulates the client holding down left click to mine the
97/// block that it's facing, but this only interacts with blocks and not
98/// entities.
99#[derive(Component)]
100pub struct LeftClickMine;
101
102#[allow(clippy::type_complexity)]
103fn handle_auto_mine(
104    mut query: Query<
105        (
106            &HitResultComponent,
107            Entity,
108            Option<&Mining>,
109            &Inventory,
110            &MineBlockPos,
111            &MineItem,
112        ),
113        With<LeftClickMine>,
114    >,
115    mut start_mining_block_event: MessageWriter<StartMiningBlockEvent>,
116    mut stop_mining_block_event: MessageWriter<StopMiningBlockEvent>,
117) {
118    for (
119        hit_result_component,
120        entity,
121        mining,
122        inventory,
123        current_mining_pos,
124        current_mining_item,
125    ) in &mut query.iter_mut()
126    {
127        let block_pos = hit_result_component
128            .as_block_hit_result_if_not_miss()
129            .map(|b| b.block_pos);
130
131        // start mining if we're looking at a block and we're not already mining it
132        if let Some(block_pos) = block_pos
133            && (mining.is_none()
134                || !is_same_mining_target(
135                    block_pos,
136                    inventory,
137                    current_mining_pos,
138                    current_mining_item,
139                ))
140        {
141            start_mining_block_event.write(StartMiningBlockEvent {
142                entity,
143                position: block_pos,
144                force: true,
145            });
146        } else if mining.is_some() && hit_result_component.miss() {
147            stop_mining_block_event.write(StopMiningBlockEvent { entity });
148        }
149    }
150}
151
152/// Information about the block we're currently mining.
153///
154/// This is only present if we're currently mining a block.
155#[derive(Component, Debug, Clone)]
156pub struct Mining {
157    pub pos: BlockPos,
158    pub dir: Direction,
159    /// See [`MiningQueued::force`].
160    pub force: bool,
161}
162
163/// Start mining the block at the given position.
164///
165/// If we're looking at the block then the correct direction will be used,
166/// otherwise it'll be [`Direction::Down`].
167#[derive(Message, Debug)]
168pub struct StartMiningBlockEvent {
169    pub entity: Entity,
170    pub position: BlockPos,
171    /// Whether we should ignore blocks that are blocking the view of this
172    /// block.
173    ///
174    /// Most of the time, you'll want to set this to true as it'll make the
175    /// behavior more predictable. If it's set to false, then it might fail or
176    /// it might mine blocks other than the one at `position` (which may be
177    /// preferable if you're trying to act like vanilla).
178    pub force: bool,
179}
180fn handle_start_mining_block_event(
181    mut commands: Commands,
182    mut events: MessageReader<StartMiningBlockEvent>,
183    mut query: Query<&HitResultComponent>,
184) {
185    for event in events.read() {
186        trace!("{event:?}");
187        let hit_result = query.get_mut(event.entity).unwrap();
188        if event.force {
189            let direction = if let Some(block_hit_result) =
190                hit_result.as_block_hit_result_if_not_miss()
191                && block_hit_result.block_pos == event.position
192            {
193                // we're looking at the block
194                block_hit_result.direction
195            } else {
196                debug!(
197                    "Got StartMiningBlockEvent but we're not looking at the block ({hit_result:?}.block_pos != {:?}). Picking an arbitrary direction instead.",
198                    event.position
199                );
200                // we're not looking at the block, arbitrary direction
201                Direction::Down
202            };
203            commands.entity(event.entity).insert(MiningQueued {
204                position: event.position,
205                direction,
206                force: true,
207            });
208        } else {
209            // let block_hit_result = hit_result.as_block_hit_result_if_not_miss();
210            // let direction = block_hit_result.map_or(Direction::Down, |b| b.direction);
211            if let Some(block_hit_result) = hit_result.as_block_hit_result_if_not_miss()
212                && block_hit_result.block_pos == event.position
213            {
214                commands.entity(event.entity).insert(MiningQueued {
215                    position: event.position,
216                    direction: block_hit_result.direction,
217                    force: false,
218                });
219            } else {
220                warn!(
221                    "Got StartMiningBlockEvent with force=false but we're not looking at the block ({hit_result:?}.block_pos != {:?}). You should've looked at the block before trying to mine with force=false.",
222                    event.position
223                );
224            };
225        }
226    }
227}
228
229/// Present on entities when they're going to start mining a block next tick.
230#[derive(Component, Debug, Clone)]
231pub struct MiningQueued {
232    pub position: BlockPos,
233    pub direction: Direction,
234    /// Whether we should mine the block regardless of whether it's reachable.
235    pub force: bool,
236}
237#[allow(clippy::too_many_arguments, clippy::type_complexity)]
238pub fn handle_mining_queued(
239    mut commands: Commands,
240    mut attack_block_events: MessageWriter<AttackBlockEvent>,
241    mut mine_block_progress_events: MessageWriter<MineBlockProgressEvent>,
242    query: Query<(
243        Entity,
244        &MiningQueued,
245        &InstanceHolder,
246        &LocalGameMode,
247        &Inventory,
248        &ActiveEffects,
249        &FluidOnEyes,
250        &Physics,
251        Option<&mut Mining>,
252        &mut BlockStatePredictionHandler,
253        &mut MineDelay,
254        &mut MineProgress,
255        &mut MineTicks,
256        &mut MineItem,
257        &mut MineBlockPos,
258    )>,
259) {
260    for (
261        entity,
262        mining_queued,
263        instance_holder,
264        game_mode,
265        inventory,
266        active_effects,
267        fluid_on_eyes,
268        physics,
269        mut mining,
270        mut sequence_number,
271        mut mine_delay,
272        mut mine_progress,
273        mut mine_ticks,
274        mut current_mining_item,
275        mut current_mining_pos,
276    ) in query
277    {
278        trace!("handle_mining_queued {mining_queued:?}");
279        commands.entity(entity).remove::<MiningQueued>();
280
281        let instance = instance_holder.instance.read();
282        if check_is_interaction_restricted(
283            &instance,
284            mining_queued.position,
285            &game_mode.current,
286            inventory,
287        ) {
288            continue;
289        }
290        // TODO (when world border is implemented): vanilla ignores if the block
291        // is outside of the worldborder
292
293        if let Some(mining) = &mut mining {
294            // this matters if we were previously mining a block without force
295            if mining_queued.force {
296                mining.force = true;
297            }
298        }
299
300        if game_mode.current == GameMode::Creative {
301            // In creative mode, first send START_DESTROY_BLOCK packet then immediately
302            // finish mining
303            commands.trigger(SendGamePacketEvent::new(
304                entity,
305                ServerboundPlayerAction {
306                    action: s_player_action::Action::StartDestroyBlock,
307                    pos: mining_queued.position,
308                    direction: mining_queued.direction,
309                    seq: sequence_number.start_predicting(),
310                },
311            ));
312            commands.trigger(FinishMiningBlockEvent {
313                entity,
314                position: mining_queued.position,
315            });
316            **mine_delay = 5;
317            commands.trigger(SwingArmEvent { entity });
318        } else if mining.is_none()
319            || !is_same_mining_target(
320                mining_queued.position,
321                inventory,
322                &current_mining_pos,
323                &current_mining_item,
324            )
325        {
326            if mining.is_some() {
327                // send a packet to stop mining since we just changed target
328                commands.trigger(SendGamePacketEvent::new(
329                    entity,
330                    ServerboundPlayerAction {
331                        action: s_player_action::Action::AbortDestroyBlock,
332                        pos: current_mining_pos
333                            .expect("IsMining is true so MineBlockPos must be present"),
334                        direction: mining_queued.direction,
335                        seq: 0,
336                    },
337                ));
338            }
339
340            let target_block_state = instance
341                .get_block_state(mining_queued.position)
342                .unwrap_or_default();
343
344            // we can't break blocks if they don't have a bounding box
345            let block_is_solid = !target_block_state.outline_shape().is_empty();
346
347            if block_is_solid && **mine_progress == 0. {
348                // interact with the block (like note block left click) here
349                attack_block_events.write(AttackBlockEvent {
350                    entity,
351                    position: mining_queued.position,
352                });
353            }
354
355            let block = Box::<dyn BlockTrait>::from(target_block_state);
356
357            let held_item = inventory.held_item();
358
359            if block_is_solid
360                && get_mine_progress(
361                    block.as_ref(),
362                    held_item.kind(),
363                    &inventory.inventory_menu,
364                    fluid_on_eyes,
365                    physics,
366                    active_effects,
367                ) >= 1.
368            {
369                // block was broken instantly (instamined)
370                commands.trigger(FinishMiningBlockEvent {
371                    entity,
372                    position: mining_queued.position,
373                });
374            } else {
375                let mining = Mining {
376                    pos: mining_queued.position,
377                    dir: mining_queued.direction,
378                    force: mining_queued.force,
379                };
380                trace!("inserting mining component {mining:?} for entity {entity:?}");
381                commands.entity(entity).insert(mining);
382                **current_mining_pos = Some(mining_queued.position);
383                **current_mining_item = held_item;
384                **mine_progress = 0.;
385                **mine_ticks = 0.;
386                mine_block_progress_events.write(MineBlockProgressEvent {
387                    entity,
388                    position: mining_queued.position,
389                    destroy_stage: mine_progress.destroy_stage(),
390                });
391            }
392
393            commands.trigger(SendGamePacketEvent::new(
394                entity,
395                ServerboundPlayerAction {
396                    action: s_player_action::Action::StartDestroyBlock,
397                    pos: mining_queued.position,
398                    direction: mining_queued.direction,
399                    seq: sequence_number.start_predicting(),
400                },
401            ));
402            commands.trigger(SwingArmEvent { entity });
403            // another swing packet gets sent in the same tick in
404            // continue_mining_block, vanilla does this too
405        }
406    }
407}
408
409#[derive(Message)]
410pub struct MineBlockProgressEvent {
411    pub entity: Entity,
412    pub position: BlockPos,
413    pub destroy_stage: Option<u32>,
414}
415
416/// A player left clicked on a block, used for stuff like interacting with note
417/// blocks.
418#[derive(Message)]
419pub struct AttackBlockEvent {
420    pub entity: Entity,
421    pub position: BlockPos,
422}
423
424/// Returns whether the block and item are still the same as when we started
425/// mining.
426fn is_same_mining_target(
427    target_block: BlockPos,
428    inventory: &Inventory,
429    current_mining_pos: &MineBlockPos,
430    current_mining_item: &MineItem,
431) -> bool {
432    let held_item = inventory.held_item();
433    Some(target_block) == current_mining_pos.0 && held_item == current_mining_item.0
434}
435
436/// A component bundle for players that can mine blocks.
437#[derive(Bundle, Default, Clone)]
438pub struct MineBundle {
439    pub delay: MineDelay,
440    pub progress: MineProgress,
441    pub ticks: MineTicks,
442    pub mining_pos: MineBlockPos,
443    pub mine_item: MineItem,
444}
445
446/// A component that counts down until we start mining the next block.
447#[derive(Component, Debug, Default, Deref, DerefMut, Clone)]
448pub struct MineDelay(pub u32);
449
450/// A component that stores the progress of the current mining operation.
451///
452/// This is a value between 0 and 1.
453#[derive(Component, Debug, Default, Deref, DerefMut, Clone)]
454pub struct MineProgress(pub f32);
455
456impl MineProgress {
457    pub fn destroy_stage(&self) -> Option<u32> {
458        if self.0 > 0. {
459            Some((self.0 * 10.) as u32)
460        } else {
461            None
462        }
463    }
464}
465
466/// A component that stores the number of ticks that we've been mining the same
467/// block for.
468///
469/// This is a float despite the fact that it should only ever be a round number.
470#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
471pub struct MineTicks(pub f32);
472
473/// A component that stores the position of the block we're currently mining.
474#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
475pub struct MineBlockPos(pub Option<BlockPos>);
476
477/// A component that contains the item we're currently using to mine, or
478/// [`ItemStack::Empty`] if nothing is being mined.
479#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
480pub struct MineItem(pub ItemStack);
481
482/// A trigger that's sent when we completed mining a block.
483#[derive(EntityEvent)]
484pub struct FinishMiningBlockEvent {
485    pub entity: Entity,
486    pub position: BlockPos,
487}
488
489pub fn handle_finish_mining_block_observer(
490    finish_mining_block: On<FinishMiningBlockEvent>,
491    mut query: Query<(
492        &InstanceName,
493        &LocalGameMode,
494        &Inventory,
495        &PlayerAbilities,
496        &PermissionLevel,
497        &Position,
498        &mut BlockStatePredictionHandler,
499    )>,
500    instances: Res<InstanceContainer>,
501) {
502    let event = finish_mining_block.event();
503
504    let (
505        instance_name,
506        game_mode,
507        inventory,
508        abilities,
509        permission_level,
510        player_pos,
511        mut prediction_handler,
512    ) = query.get_mut(finish_mining_block.entity).unwrap();
513    let instance_lock = instances.get(instance_name).unwrap();
514    let instance = instance_lock.read();
515    if check_is_interaction_restricted(&instance, event.position, &game_mode.current, inventory) {
516        return;
517    }
518
519    if game_mode.current == GameMode::Creative {
520        let held_item = inventory.held_item().kind();
521        if matches!(
522            held_item,
523            azalea_registry::Item::Trident | azalea_registry::Item::DebugStick
524        ) || azalea_registry::tags::items::SWORDS.contains(&held_item)
525        {
526            return;
527        }
528    }
529
530    let Some(block_state) = instance.get_block_state(event.position) else {
531        return;
532    };
533
534    let registry_block: azalea_registry::Block =
535        Box::<dyn BlockTrait>::from(block_state).as_registry_block();
536    if !can_use_game_master_blocks(abilities, permission_level)
537        && matches!(
538            registry_block,
539            azalea_registry::Block::CommandBlock | azalea_registry::Block::StructureBlock
540        )
541    {
542        return;
543    }
544    if block_state == BlockState::AIR {
545        return;
546    }
547
548    // when we break a waterlogged block we want to keep the water there
549    let fluid_state = FluidState::from(block_state);
550    let block_state_for_fluid = BlockState::from(fluid_state);
551    let old_state = instance
552        .set_block_state(event.position, block_state_for_fluid)
553        .unwrap_or_default();
554    prediction_handler.retain_known_server_state(event.position, old_state, **player_pos);
555}
556
557/// Abort mining a block.
558#[derive(Message)]
559pub struct StopMiningBlockEvent {
560    pub entity: Entity,
561}
562pub fn handle_stop_mining_block_event(
563    mut events: MessageReader<StopMiningBlockEvent>,
564    mut commands: Commands,
565    mut mine_block_progress_events: MessageWriter<MineBlockProgressEvent>,
566    mut query: Query<(&MineBlockPos, &mut MineProgress)>,
567) {
568    for event in events.read() {
569        let (mine_block_pos, mut mine_progress) = query.get_mut(event.entity).unwrap();
570
571        let mine_block_pos =
572            mine_block_pos.expect("IsMining is true so MineBlockPos must be present");
573        commands.trigger(SendGamePacketEvent::new(
574            event.entity,
575            ServerboundPlayerAction {
576                action: s_player_action::Action::AbortDestroyBlock,
577                pos: mine_block_pos,
578                direction: Direction::Down,
579                seq: 0,
580            },
581        ));
582        commands.entity(event.entity).remove::<Mining>();
583        **mine_progress = 0.;
584        mine_block_progress_events.write(MineBlockProgressEvent {
585            entity: event.entity,
586            position: mine_block_pos,
587            destroy_stage: None,
588        });
589    }
590}
591
592#[allow(clippy::too_many_arguments, clippy::type_complexity)]
593pub fn continue_mining_block(
594    mut query: Query<(
595        Entity,
596        &InstanceName,
597        &LocalGameMode,
598        &Inventory,
599        &MineBlockPos,
600        &MineItem,
601        &ActiveEffects,
602        &FluidOnEyes,
603        &Physics,
604        &Mining,
605        &mut MineDelay,
606        &mut MineProgress,
607        &mut MineTicks,
608        &mut BlockStatePredictionHandler,
609    )>,
610    mut commands: Commands,
611    mut mine_block_progress_events: MessageWriter<MineBlockProgressEvent>,
612    instances: Res<InstanceContainer>,
613) {
614    for (
615        entity,
616        instance_name,
617        game_mode,
618        inventory,
619        current_mining_pos,
620        current_mining_item,
621        active_effects,
622        fluid_on_eyes,
623        physics,
624        mining,
625        mut mine_delay,
626        mut mine_progress,
627        mut mine_ticks,
628        mut prediction_handler,
629    ) in query.iter_mut()
630    {
631        if **mine_delay > 0 {
632            **mine_delay -= 1;
633            continue;
634        }
635
636        if game_mode.current == GameMode::Creative {
637            // TODO: worldborder check
638            **mine_delay = 5;
639            commands.trigger(SendGamePacketEvent::new(
640                entity,
641                ServerboundPlayerAction {
642                    action: s_player_action::Action::StartDestroyBlock,
643                    pos: mining.pos,
644                    direction: mining.dir,
645                    seq: prediction_handler.start_predicting(),
646                },
647            ));
648            commands.trigger(FinishMiningBlockEvent {
649                entity,
650                position: mining.pos,
651            });
652            commands.trigger(SwingArmEvent { entity });
653        } else if mining.force
654            || is_same_mining_target(
655                mining.pos,
656                inventory,
657                current_mining_pos,
658                current_mining_item,
659            )
660        {
661            trace!("continue mining block at {:?}", mining.pos);
662            let instance_lock = instances.get(instance_name).unwrap();
663            let instance = instance_lock.read();
664            let target_block_state = instance.get_block_state(mining.pos).unwrap_or_default();
665
666            trace!("target_block_state: {target_block_state:?}");
667
668            if target_block_state.is_air() {
669                commands.entity(entity).remove::<Mining>();
670                continue;
671            }
672            let block = Box::<dyn BlockTrait>::from(target_block_state);
673            **mine_progress += get_mine_progress(
674                block.as_ref(),
675                current_mining_item.kind(),
676                &inventory.inventory_menu,
677                fluid_on_eyes,
678                physics,
679                active_effects,
680            );
681
682            if **mine_ticks % 4. == 0. {
683                // vanilla makes a mining sound here
684            }
685            **mine_ticks += 1.;
686
687            if **mine_progress >= 1. {
688                // MiningQueued is removed in case we were doing an infinite loop that
689                // repeatedly inserts MiningQueued
690                commands.entity(entity).remove::<(Mining, MiningQueued)>();
691                trace!("finished mining block at {:?}", mining.pos);
692                commands.trigger(FinishMiningBlockEvent {
693                    entity,
694                    position: mining.pos,
695                });
696                commands.trigger(SendGamePacketEvent::new(
697                    entity,
698                    ServerboundPlayerAction {
699                        action: s_player_action::Action::StopDestroyBlock,
700                        pos: mining.pos,
701                        direction: mining.dir,
702                        seq: prediction_handler.start_predicting(),
703                    },
704                ));
705                **mine_progress = 0.;
706                **mine_ticks = 0.;
707                **mine_delay = 5;
708            }
709
710            mine_block_progress_events.write(MineBlockProgressEvent {
711                entity,
712                position: mining.pos,
713                destroy_stage: mine_progress.destroy_stage(),
714            });
715            commands.trigger(SwingArmEvent { entity });
716        } else {
717            trace!("switching mining target to {:?}", mining.pos);
718            commands.entity(entity).insert(MiningQueued {
719                position: mining.pos,
720                direction: mining.dir,
721                force: false,
722            });
723        }
724    }
725}
726
727pub fn update_mining_component(
728    mut commands: Commands,
729    mut query: Query<(Entity, &mut Mining, &HitResultComponent)>,
730) {
731    for (entity, mut mining, hit_result_component) in &mut query.iter_mut() {
732        if let Some(block_hit_result) = hit_result_component.as_block_hit_result_if_not_miss() {
733            if mining.force && block_hit_result.block_pos != mining.pos {
734                continue;
735            }
736
737            if mining.pos != block_hit_result.block_pos {
738                debug!(
739                    "Updating Mining::pos from {:?} to {:?}",
740                    mining.pos, block_hit_result.block_pos
741                );
742                mining.pos = block_hit_result.block_pos;
743            }
744            mining.dir = block_hit_result.direction;
745        } else {
746            if mining.force {
747                continue;
748            }
749
750            debug!("Removing mining component because we're no longer looking at the block");
751            commands.entity(entity).remove::<Mining>();
752        }
753    }
754}