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