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