Skip to main content

steel_core/player/game_mode/
block_breaking.rs

1//! Block breaking state machine for players.
2//!
3//! This module implements the logic from Java's `ServerPlayerGameMode` for handling
4//! block breaking, including progress tracking and validation.
5
6use std::sync::Arc;
7
8use steel_protocol::packets::game::CBlockUpdate;
9use steel_registry::attribute::AttributeRef;
10use steel_registry::blocks::block_state_ext::BlockStateExt;
11use steel_registry::equipment::EquipmentSlot;
12use steel_registry::stat::vanilla_stat_types;
13use steel_registry::{
14    REGISTRY, blocks::properties::Direction, item_stack::ItemStack, level_events, vanilla_blocks,
15    vanilla_game_events,
16};
17use steel_registry::{vanilla_attributes, vanilla_mob_effects};
18use steel_utils::{
19    BlockPos, BlockStateId,
20    types::{GameType, InteractionHand, UpdateFlags},
21};
22
23use crate::behavior::{BLOCK_BEHAVIORS, BlockLootContext};
24use crate::block_entity::BlockEntity;
25use crate::entity::{ActiveMobEffect, Entity, LivingEntity};
26use crate::fluid::fluid_state_to_block;
27use crate::player::Player;
28use crate::player::food_data::food_constants;
29use crate::world::{ConditionalBlockSetResult, World, game_event::GameEventContext};
30
31use super::adventure_mode;
32
33impl Player {
34    /// Mining speed for `state` with the held item, after attributes, effects and
35    /// position penalties.
36    fn get_destroy_speed(&self, state: BlockStateId) -> f32 {
37        let mut speed = {
38            let inv = self.inventory.lock();
39            inv.get_item_in_hand(InteractionHand::MainHand)
40                .get_destroy_speed(state)
41        };
42
43        let attributes = self.attributes().lock();
44        let attribute_value = |attribute: AttributeRef| {
45            attributes
46                .get_value(attribute)
47                .unwrap_or(attribute.default_value) as f32
48        };
49
50        // TODO: Efficiency and Aqua Affinity have no effect until the enchantment
51        // `attributes` effect (`EnchantmentHelper.runLocationChangedEffects`) installs
52        // its attribute modifiers on equipment changes.
53        if speed > 1.0 {
54            speed += attribute_value(vanilla_attributes::MINING_EFFICIENCY);
55        }
56
57        let haste = self.mob_effect(vanilla_mob_effects::HASTE);
58        let conduit_power = self.mob_effect(vanilla_mob_effects::CONDUIT_POWER);
59        let dig_speed_amplifier = haste
60            .iter()
61            .chain(conduit_power.iter())
62            .map(ActiveMobEffect::amplifier)
63            .max();
64        if let Some(amplifier) = dig_speed_amplifier {
65            speed *= 1.0 + (amplifier + 1) as f32 * 0.2;
66        }
67
68        if let Some(mining_fatigue) = self.mob_effect(vanilla_mob_effects::MINING_FATIGUE) {
69            speed *= 0.3_f32.powi(mining_fatigue.amplifier() + 1);
70        }
71
72        speed *= attribute_value(vanilla_attributes::BLOCK_BREAK_SPEED);
73        if self.is_eye_in_water() {
74            speed *= attribute_value(vanilla_attributes::SUBMERGED_MINING_SPEED);
75        }
76
77        if !self.on_ground() {
78            speed /= 5.0;
79        }
80
81        speed
82    }
83
84    /// Mirrors vanilla `Player.blockActionRestricted` for block breaking.
85    pub(super) fn block_action_restricted(&self, world: &World, pos: BlockPos) -> bool {
86        let game_mode = self.game_mode();
87        if !matches!(game_mode, GameType::Adventure | GameType::Spectator) {
88            return false;
89        }
90        if game_mode == GameType::Spectator {
91            return true;
92        }
93        if self.abilities.lock().may_build {
94            return false;
95        }
96
97        let item = {
98            let inventory = self.inventory.lock();
99            let item = inventory.get_selected_item();
100            if item.is_empty() {
101                return true;
102            }
103            item.clone()
104        };
105        !adventure_mode::can_break(&item, world, pos)
106    }
107}
108
109/// Manages the block breaking state for a player.
110///
111/// Based on Java's `ServerPlayerGameMode` fields and logic.
112pub struct BlockBreakingManager {
113    /// Whether the player is currently breaking a block.
114    is_destroying_block: bool,
115    /// The tick when destruction started.
116    destroy_progress_start: u64,
117    /// The position of the block being destroyed.
118    destroy_pos: BlockPos,
119    /// The face being mined, used as the data value for the destroy-progress level event.
120    destroy_direction: Direction,
121    /// The current game tick counter.
122    game_ticks: u64,
123    /// Whether there's a delayed destroy pending (for slow mining).
124    has_delayed_destroy: bool,
125    /// Position of the delayed destroy.
126    delayed_destroy_pos: BlockPos,
127    /// The tick when delayed destroy started.
128    delayed_tick_start: u64,
129    /// The last sent destruction progress state (0-9, or -1 for none).
130    last_sent_state: i32,
131}
132
133impl Default for BlockBreakingManager {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl BlockBreakingManager {
140    /// Creates a new block breaking manager.
141    #[must_use]
142    pub const fn new() -> Self {
143        Self {
144            is_destroying_block: false,
145            destroy_progress_start: 0,
146            destroy_pos: BlockPos::new(0, 0, 0),
147            destroy_direction: Direction::Down,
148            game_ticks: 0,
149            has_delayed_destroy: false,
150            delayed_destroy_pos: BlockPos::new(0, 0, 0),
151            delayed_tick_start: 0,
152            last_sent_state: -1,
153        }
154    }
155
156    /// Ticks the block breaking manager.
157    ///
158    /// This handles delayed destruction and updates break progress.
159    pub fn tick(&mut self, player: &Player, world: &Arc<World>) {
160        self.game_ticks += 1;
161
162        if self.has_delayed_destroy {
163            let state = world.get_block_state(self.delayed_destroy_pos);
164            if is_air(state) {
165                self.has_delayed_destroy = false;
166            } else {
167                let progress = self.increment_destroy_progress(
168                    player,
169                    world,
170                    state,
171                    self.delayed_destroy_pos,
172                    self.delayed_tick_start,
173                );
174                if progress >= 1.0 {
175                    self.has_delayed_destroy = false;
176                    self.destroy_block(player, world, self.delayed_destroy_pos);
177                }
178            }
179        } else if self.is_destroying_block {
180            let state = world.get_block_state(self.destroy_pos);
181            if is_air(state) {
182                // Block was broken by something else
183                world.broadcast_block_destruction(player.id(), self.destroy_pos, -1);
184                self.last_sent_state = -1;
185                self.is_destroying_block = false;
186            } else {
187                let ticks_spent_destroying =
188                    self.game_ticks.saturating_sub(self.destroy_progress_start);
189                let event = if ticks_spent_destroying.is_multiple_of(4) {
190                    level_events::PARTICLES_AND_SOUND_DESTROY_PROGRESS
191                } else {
192                    level_events::PARTICLES_DESTROY_PROGRESS
193                };
194
195                self.increment_destroy_progress(
196                    player,
197                    world,
198                    state,
199                    self.destroy_pos,
200                    self.destroy_progress_start,
201                );
202                world.level_event(
203                    event,
204                    self.destroy_pos,
205                    self.destroy_direction.get_3d_data_value(),
206                    None,
207                );
208            }
209        }
210    }
211
212    /// Calculates and updates destruction progress, broadcasting to clients.
213    fn increment_destroy_progress(
214        &mut self,
215        player: &Player,
216        world: &Arc<World>,
217        block_state: BlockStateId,
218        pos: BlockPos,
219        destroy_start_tick: u64,
220    ) -> f32 {
221        let ticks_spent = self.game_ticks.saturating_sub(destroy_start_tick);
222        let destroy_speed = get_destroy_progress(player, block_state);
223        let progress = destroy_speed * (ticks_spent + 1) as f32;
224        let state = (progress * 10.0) as i32;
225
226        if state != self.last_sent_state {
227            world.broadcast_block_destruction(player.id(), pos, state);
228            self.last_sent_state = state;
229        }
230
231        progress
232    }
233
234    /// Handles a block break action from the client.
235    ///
236    /// Note: The caller (packet handler) is responsible for calling `ack_block_changes_up_to`
237    /// after this method returns, matching vanilla behavior.
238    pub fn handle_block_break_action(
239        &mut self,
240        player: &Player,
241        world: &Arc<World>,
242        pos: BlockPos,
243        action: BlockBreakAction,
244        direction: Direction,
245    ) {
246        // Validate interaction range
247        if !player.is_within_block_interaction_range(pos) {
248            if self.is_destroying_block && action == BlockBreakAction::Abort {
249                self.abort_destroy_block(player, world, pos);
250            }
251            return;
252        }
253
254        // Validate Y coordinate
255        if pos.y() >= world.max_build_height() {
256            player.send_packet(CBlockUpdate {
257                pos,
258                block_state: world.get_block_state(pos),
259            });
260            return;
261        }
262
263        match action {
264            BlockBreakAction::ChangeDirection => {
265                self.destroy_direction = direction;
266            }
267
268            BlockBreakAction::Start => {
269                // Check may_interact permission
270                if !world.may_interact(player, pos) {
271                    player.send_packet(CBlockUpdate {
272                        pos,
273                        block_state: world.get_block_state(pos),
274                    });
275                    return;
276                }
277
278                // Creative mode: instant break
279                if player.game_mode() == GameType::Creative {
280                    self.destroy_and_ack(player, world, pos);
281                    return;
282                }
283
284                if player.block_action_restricted(world, pos) {
285                    player.send_packet(CBlockUpdate {
286                        pos,
287                        block_state: world.get_block_state(pos),
288                    });
289                    return;
290                }
291
292                self.destroy_progress_start = self.game_ticks;
293                let block_state = world.get_block_state(pos);
294
295                if !is_air(block_state) {
296                    // TODO: Call EnchantmentHelper.onHitBlock before blockState.attack.
297                    BLOCK_BEHAVIORS
298                        .get_behavior(block_state.get_block())
299                        .attack(block_state, world, pos, player);
300
301                    let progress = get_destroy_progress(player, block_state);
302
303                    if progress >= 1.0 {
304                        // Insta-mine
305                        self.destroy_and_ack(player, world, pos);
306                    } else {
307                        // Start breaking
308                        if self.is_destroying_block {
309                            // Send block update for the old position to cancel client prediction
310                            player.send_packet(CBlockUpdate {
311                                pos: self.destroy_pos,
312                                block_state: world.get_block_state(self.destroy_pos),
313                            });
314                        }
315
316                        self.is_destroying_block = true;
317                        self.destroy_pos = pos;
318                        self.destroy_direction = direction;
319                        let state = (progress * 10.0) as i32;
320                        world.broadcast_block_destruction(player.id(), pos, state);
321                        self.last_sent_state = state;
322                    }
323                }
324            }
325
326            BlockBreakAction::Stop => {
327                if pos == self.destroy_pos {
328                    let ticks_spent = self.game_ticks.saturating_sub(self.destroy_progress_start);
329                    let block_state = world.get_block_state(pos);
330
331                    if !is_air(block_state) {
332                        let destroy_speed = get_destroy_progress(player, block_state);
333                        let progress = destroy_speed * (ticks_spent + 1) as f32;
334
335                        if progress >= 0.7 {
336                            // Complete the break
337                            self.is_destroying_block = false;
338                            world.broadcast_block_destruction(player.id(), pos, -1);
339                            self.destroy_and_ack(player, world, pos);
340                            return;
341                        }
342
343                        if !self.has_delayed_destroy {
344                            // Set up delayed destroy
345                            self.is_destroying_block = false;
346                            self.has_delayed_destroy = true;
347                            self.delayed_destroy_pos = pos;
348                            self.delayed_tick_start = self.destroy_progress_start;
349                        }
350                    }
351                }
352            }
353
354            BlockBreakAction::Abort => {
355                self.abort_destroy_block(player, world, pos);
356            }
357        }
358    }
359
360    fn abort_destroy_block(&mut self, player: &Player, world: &Arc<World>, pos: BlockPos) {
361        self.is_destroying_block = false;
362
363        if self.destroy_pos != pos {
364            log::warn!(
365                "Mismatch in destroy block pos: {:?} vs {:?}",
366                self.destroy_pos,
367                pos
368            );
369            world.broadcast_block_destruction(player.id(), self.destroy_pos, -1);
370        }
371
372        world.broadcast_block_destruction(player.id(), pos, -1);
373    }
374
375    /// Destroys a block and sends appropriate response.
376    fn destroy_and_ack(&mut self, player: &Player, world: &Arc<World>, pos: BlockPos) {
377        if !self.destroy_block(player, world, pos) {
378            // Send block update to resync client
379            player.send_packet(CBlockUpdate {
380                pos,
381                block_state: world.get_block_state(pos),
382            });
383        }
384    }
385
386    /// Destroys a block at the given position.
387    ///
388    /// Returns true if the block was successfully destroyed.
389    #[expect(
390        clippy::unused_self,
391        reason = "method belongs logically to BlockBreakingManager and will use self when additional state is added"
392    )]
393    fn destroy_block(&self, player: &Player, world: &Arc<World>, pos: BlockPos) -> bool {
394        let state = world.get_block_state(pos);
395
396        if player.get_abilities().instabuild && !held_item_can_destroy_in_creative(player) {
397            return false;
398        }
399
400        // Get block info
401        let Some(_block) = REGISTRY.blocks.by_state_id(state) else {
402            return false;
403        };
404
405        // TODO: Check for GameMasterBlock (command blocks, etc.)
406
407        if player.block_action_restricted(world, pos) {
408            return false;
409        }
410
411        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
412        let adjusted_state = behavior.player_will_destroy(state, world, pos, player);
413        world.game_event(
414            &vanilla_game_events::BLOCK_DESTROY,
415            pos,
416            &GameEventContext::new(Some(player), Some(adjusted_state)),
417        );
418        let state_after_player_will_destroy = world.get_block_state(pos);
419
420        // Vanilla parity: fluidState.createLegacyBlock() — breaking a waterlogged
421        // block leaves water behind instead of air.
422        let replacement = fluid_state_to_block(state.get_fluid_state());
423
424        // Read before the removal below, so loot generation still sees the
425        // block entity's contents.
426        let block_entity = world.get_block_entity(pos);
427
428        // Vanilla removes the live state after `playerWillDestroy`; tripwire uses
429        // that callback to set DISARMED before the same block is removed.
430        let removed_by_player_break = !state_after_player_will_destroy.is_air()
431            && world.set_block_if_unchanged(
432                pos,
433                state_after_player_will_destroy,
434                replacement,
435                UpdateFlags::UPDATE_ALL,
436            ) == ConditionalBlockSetResult::Changed;
437        let changed_by_player_will_destroy = state_after_player_will_destroy != state;
438        let changed = changed_by_player_will_destroy || removed_by_player_break;
439
440        if removed_by_player_break {
441            behavior.destroy(adjusted_state, world, pos);
442
443            // Play block destruction particles and sound (skip for fire blocks like vanilla)
444            // Exclude the breaking player as they see the effect client-side
445            let block = REGISTRY.blocks.by_state_id(adjusted_state);
446            let is_fire = block.is_some_and(|b| {
447                b.key == vanilla_blocks::FIRE.key || b.key == vanilla_blocks::SOUL_FIRE.key
448            });
449            if !is_fire {
450                world.destroy_block_effect(pos, u32::from(adjusted_state.0), Some(player.id()));
451            }
452
453            // Vanilla snapshots the tool before Item.mineBlock can damage or
454            // consume it, then uses that snapshot for loot and post-break effects.
455            let (has_correct_tool, destroyed_with) = {
456                let inv = player.inventory.lock();
457                let main_hand = inv.get_item_in_hand(InteractionHand::MainHand);
458                (
459                    main_hand.is_correct_tool_for_drops(adjusted_state)
460                        || !requires_correct_tool(adjusted_state),
461                    main_hand.copy_with_count(main_hand.count),
462                )
463            };
464
465            // Damage the tool if the block has non-zero destroy time
466            // This is done before playerDestroy, matching vanilla's Item.mineBlock
467            let block_destroy_time = REGISTRY
468                .blocks
469                .by_state_id(adjusted_state)
470                .map_or(0.0, |b| b.config.destroy_time);
471
472            if block_destroy_time != 0.0 {
473                let broke = {
474                    let mut inv = player.inventory.lock();
475                    let damage_per_block = inv.get_selected_item().get_tool_damage_per_block();
476
477                    if damage_per_block > 0 {
478                        // Use with_selected_item_mut to ensure set_changed() is called
479                        // Skip damage if player has infinite materials (creative mode)
480                        let has_infinite_materials = player.has_infinite_materials();
481                        inv.with_selected_item_mut(|main_hand| {
482                            let item = main_hand.item;
483                            main_hand
484                                .hurt_and_break(damage_per_block, has_infinite_materials)
485                                .then_some(item)
486                        })
487                    } else {
488                        None
489                    }
490                };
491
492                if let Some(item) = broke {
493                    player.on_equipped_item_broken(item, EquipmentSlot::MainHand);
494                    log::debug!("Tool broke while mining block at {pos:?}");
495                }
496            }
497
498            // Handle drops (skip for creative/spectator)
499            let game_mode = player.game_mode();
500            if game_mode != GameType::Spectator
501                && game_mode != GameType::Creative
502                && has_correct_tool
503            {
504                player.award_stat(&vanilla_stat_types::BLOCK_MINED, state.get_block());
505                player.cause_food_exhaustion(food_constants::EXHAUSTION_MINE);
506
507                drop_block_loot(
508                    player,
509                    world,
510                    pos,
511                    adjusted_state,
512                    &destroyed_with,
513                    block_entity.as_deref(),
514                );
515                behavior.player_destroy(
516                    world,
517                    player,
518                    pos,
519                    adjusted_state,
520                    block_entity.as_ref(),
521                    &destroyed_with,
522                );
523            }
524        }
525
526        changed
527    }
528}
529
530/// Block break action types.
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum BlockBreakAction {
533    /// Player started breaking a block.
534    Start,
535    /// Player redirected the block-break raycast without resetting progress.
536    ChangeDirection,
537    /// Player stopped breaking a block (finished or released).
538    Stop,
539    /// Player aborted breaking a block.
540    Abort,
541}
542
543fn held_item_can_destroy_in_creative(player: &Player) -> bool {
544    let inventory = player.inventory.lock();
545    let main_hand = inventory.get_item_in_hand(InteractionHand::MainHand);
546    main_hand.can_destroy_blocks_in_creative()
547}
548
549/// Checks if a block state is air.
550fn is_air(state: BlockStateId) -> bool {
551    let Some(block) = REGISTRY.blocks.by_state_id(state) else {
552        return true;
553    };
554    block.config.is_air
555}
556
557/// Checks if a block requires the correct tool for drops.
558fn requires_correct_tool(state: BlockStateId) -> bool {
559    let Some(block) = REGISTRY.blocks.by_state_id(state) else {
560        return false;
561    };
562    block.config.requires_correct_tool_for_drops
563}
564
565/// Gets the destroy progress per tick for a block.
566///
567/// This is based on the vanilla formula:
568/// `1.0 / (destroy_time * 30.0)` for survival with correct tool
569/// `1.0 / (destroy_time * 100.0)` for survival with wrong tool
570/// Instant break for creative mode.
571fn get_destroy_progress(player: &Player, block_state: BlockStateId) -> f32 {
572    let Some(block) = REGISTRY.blocks.by_state_id(block_state) else {
573        return 0.0;
574    };
575
576    let destroy_time = block.config.destroy_time;
577
578    // Instant break for creative
579    if player.game_mode() == GameType::Creative {
580        return 1.0;
581    }
582
583    // Unbreakable block
584    if destroy_time < 0.0 {
585        return 0.0;
586    }
587
588    // Instant break for destroy_time == 0
589    if destroy_time == 0.0 {
590        return 1.0;
591    }
592
593    let speed = player.get_destroy_speed(block_state);
594
595    let has_correct_tool = {
596        let inv = player.inventory.lock();
597        let main_hand = inv.get_item_in_hand(InteractionHand::MainHand);
598        main_hand.is_correct_tool_for_drops(block_state)
599    };
600
601    // Calculate destroy progress per tick
602    // Vanilla formula: speed / hardness / (hasCorrectTool ? 30 : 100)
603    let divisor = if has_correct_tool || !block.config.requires_correct_tool_for_drops {
604        30.0
605    } else {
606        100.0
607    };
608
609    speed / destroy_time / divisor
610}
611
612/// Drops loot for a destroyed block using its loot table.
613fn drop_block_loot(
614    player: &Player,
615    world: &Arc<World>,
616    pos: BlockPos,
617    state: BlockStateId,
618    tool: &ItemStack,
619    block_entity: Option<&dyn BlockEntity>,
620) {
621    let luck = player
622        .attributes()
623        .lock()
624        .get_value(vanilla_attributes::LUCK)
625        .unwrap_or(0.0) as f32;
626
627    let drops = BlockLootContext::new(world, pos)
628        .with_luck(luck)
629        .with_tool(tool)
630        .with_block_entity(block_entity)
631        .get_drops(state);
632
633    // Spawn each dropped item using the player's world reference (Arc<World>)
634    for item in drops {
635        if !item.is_empty() {
636            player.get_world().pop_resource(pos, item);
637        }
638    }
639
640    BLOCK_BEHAVIORS
641        .get_behavior(state.get_block())
642        .spawn_after_break(state, world, pos, tool, true);
643}