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