Skip to main content

steel_core/block_entity/entities/
brushable.rs

1//! Brushable block entity for archaeology brush progress and delayed loot.
2
3use std::str::FromStr as _;
4use std::sync::{Arc, Weak};
5
6use glam::DVec3;
7use rand::{SeedableRng as _, rngs::StdRng};
8use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
9use simdnbt::owned::NbtCompound;
10use steel_registry::blocks::block_state_ext::BlockStateExt as _;
11use steel_registry::blocks::properties::BlockStateProperties;
12use steel_registry::item_stack::ItemStack;
13use steel_registry::loot_table::{LootContext, LootTableRef};
14use steel_registry::{
15    REGISTRY, RegistryExt as _, vanilla_block_entity_types, vanilla_blocks, vanilla_entities,
16};
17use steel_utils::types::UpdateFlags;
18use steel_utils::{
19    BlockPos, BlockStateId, Direction, DowncastType, DowncastTypeKey, Identifier, locks::SyncMutex,
20};
21
22use crate::behavior::BLOCK_BEHAVIORS;
23use crate::block_entity::{BlockEntity, BlockEntityBase};
24use crate::entity::{LivingEntity as _, entity_loot_ref};
25use crate::player::Player;
26use crate::world::World;
27
28const BRUSH_COOLDOWN_TICKS: i64 = 10;
29const BRUSH_RESET_TICKS: i64 = 40;
30const REQUIRED_BRUSHES: i32 = 10;
31const RESET_BRUSH_COUNT_TICKS: i64 = 4;
32const BRUSH_COMPLETED_LEVEL_EVENT: i32 = 3008;
33
34/// `LevelChunk::set_block_state` re-locks the same block entity to update its
35/// cached state, so callers must not hold that mutex while applying these.
36#[derive(Default)]
37pub struct BrushableWorldMutation {
38    pub set_block: Option<BlockStateId>,
39    pub completed_level_event_data: Option<i32>,
40    pub drop: Option<(DVec3, ItemStack)>,
41}
42
43impl BrushableWorldMutation {
44    #[must_use]
45    pub const fn is_empty(&self) -> bool {
46        self.set_block.is_none() && self.completed_level_event_data.is_none() && self.drop.is_none()
47    }
48
49    pub fn apply(self, world: &Arc<World>, pos: BlockPos) {
50        if let Some((drop_pos, item)) = self.drop {
51            let _ = world.spawn_item_with_velocity(drop_pos, item, DVec3::ZERO);
52        }
53        if let Some(data) = self.completed_level_event_data {
54            world.level_event(BRUSH_COMPLETED_LEVEL_EVENT, pos, data, None);
55        }
56        if let Some(state) = self.set_block {
57            let _ = world.set_block(pos, state, UpdateFlags::UPDATE_ALL);
58        }
59    }
60}
61
62/// Result of one brush attempt, including deferred world work for the caller.
63pub struct BrushOutcome {
64    pub durability_damage: bool,
65    pub mutation: BrushableWorldMutation,
66}
67
68/// Stores vanilla archaeology brush progress and delayed loot for brushable blocks.
69pub struct BrushableBlockEntity {
70    base: BlockEntityBase,
71    state: SyncMutex<BrushableState>,
72}
73
74struct BrushableState {
75    brush_count: i32,
76    brush_count_resets_at_tick: i64,
77    cool_down_ends_at_tick: i64,
78    item: ItemStack,
79    hit_direction: Option<Direction>,
80    loot_table: Option<Identifier>,
81    loot_table_seed: i64,
82}
83
84// SAFETY: This key is owned by Steel and uniquely identifies `BrushableBlockEntity`.
85unsafe impl DowncastType for BrushableBlockEntity {
86    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/brushable");
87}
88
89impl BrushableBlockEntity {
90    /// Creates a brushable block entity with no active brush progress.
91    #[must_use]
92    pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
93        Self {
94            base: BlockEntityBase::new(
95                &vanilla_block_entity_types::BRUSHABLE_BLOCK,
96                level,
97                pos,
98                state,
99            ),
100            state: SyncMutex::new(BrushableState {
101                brush_count: 0,
102                brush_count_resets_at_tick: 0,
103                cool_down_ends_at_tick: 0,
104                item: ItemStack::empty(),
105                hit_direction: None,
106                loot_table: None,
107                loot_table_seed: 0,
108            }),
109        }
110    }
111
112    /// Applies one vanilla brush attempt.
113    /// Returns deferred world mutations that must be applied after the
114    /// block-entity mutex is released.
115    pub fn brush(
116        &self,
117        game_time: i64,
118        world: &Arc<World>,
119        player: &Player,
120        hit_direction: Direction,
121        brush: &ItemStack,
122    ) -> BrushOutcome {
123        let mut state = self.state.lock();
124        if state.hit_direction.is_none() {
125            state.hit_direction = Some(hit_direction);
126        }
127
128        state.brush_count_resets_at_tick = game_time + BRUSH_RESET_TICKS;
129        if game_time < state.cool_down_ends_at_tick {
130            return BrushOutcome {
131                durability_damage: false,
132                mutation: BrushableWorldMutation::default(),
133            };
134        }
135
136        state.cool_down_ends_at_tick = game_time + BRUSH_COOLDOWN_TICKS;
137        self.unpack_loot_table(&mut state, player, brush);
138
139        let previous_completion_state = state.completion_state();
140        state.brush_count += 1;
141        if state.brush_count >= REQUIRED_BRUSHES {
142            let mutation = self.brushing_completed_mutation(&mut state);
143            self.set_changed();
144            return BrushOutcome {
145                durability_damage: true,
146                mutation,
147            };
148        }
149
150        world.schedule_block_tick_default(
151            self.get_block_pos(),
152            self.get_block_state().get_block(),
153            2,
154        );
155        let mut mutation = BrushableWorldMutation::default();
156        let completion_state = state.completion_state();
157        if previous_completion_state != completion_state {
158            mutation.set_block = Some(self.with_dusted(completion_state));
159        }
160
161        self.set_changed();
162        BrushOutcome {
163            durability_damage: false,
164            mutation,
165        }
166    }
167
168    /// Applies vanilla delayed progress decay after brushing stops.
169    pub fn check_reset(&self, world: &Arc<World>) -> BrushableWorldMutation {
170        let mut state = self.state.lock();
171        let mut mutation = BrushableWorldMutation::default();
172        let game_time = world.game_time();
173
174        if state.brush_count != 0 && game_time >= state.brush_count_resets_at_tick {
175            let previous_completion_state = state.completion_state();
176            state.brush_count = 0.max(state.brush_count - 2);
177            let completion_state = state.completion_state();
178            if previous_completion_state != completion_state {
179                mutation.set_block = Some(self.with_dusted(completion_state));
180            }
181            state.brush_count_resets_at_tick = game_time + RESET_BRUSH_COUNT_TICKS;
182            self.set_changed();
183        }
184
185        if state.brush_count == 0 {
186            state.hit_direction = None;
187            state.brush_count_resets_at_tick = 0;
188            state.cool_down_ends_at_tick = 0;
189        } else {
190            world.schedule_block_tick_default(
191                self.get_block_pos(),
192                self.get_block_state().get_block(),
193                2,
194            );
195        }
196
197        mutation
198    }
199
200    fn unpack_loot_table(&self, state: &mut BrushableState, player: &Player, brush: &ItemStack) {
201        let Some(loot_table_key) = state.loot_table.take() else {
202            return;
203        };
204        let loot_table = REGISTRY.loot_tables.by_key(&loot_table_key);
205
206        if state.loot_table_seed == 0 {
207            let mut rng = rand::rng();
208            self.unpack_loot_items(state, loot_table, &loot_table_key, &mut rng, player, brush);
209        } else {
210            let mut rng = StdRng::seed_from_u64(state.loot_table_seed as u64);
211            self.unpack_loot_items(state, loot_table, &loot_table_key, &mut rng, player, brush);
212        }
213        self.set_changed();
214    }
215
216    fn unpack_loot_items<R: rand::Rng>(
217        &self,
218        state: &mut BrushableState,
219        loot_table: Option<LootTableRef>,
220        loot_table_key: &Identifier,
221        rng: &mut R,
222        player: &Player,
223        brush: &ItemStack,
224    ) {
225        let loot = match loot_table {
226            Some(table) => {
227                let mut ctx = LootContext::new(rng)
228                    .with_luck(player.get_luck())
229                    .with_tool(brush)
230                    .with_origin(
231                        f64::from(self.get_block_pos().x()) + 0.5,
232                        f64::from(self.get_block_pos().y()) + 0.5,
233                        f64::from(self.get_block_pos().z()) + 0.5,
234                    )
235                    .with_this_entity(entity_loot_ref(player));
236                table.get_random_items(&mut ctx)
237            }
238            None => Vec::new(),
239        };
240        state.item = match loot.len() {
241            0 => ItemStack::empty(),
242            1 => loot.into_iter().next().unwrap_or_else(ItemStack::empty),
243            n => {
244                log::warn!("Expected max 1 loot from loot table {loot_table_key}, but got {n}");
245                loot.into_iter().next().unwrap_or_else(ItemStack::empty)
246            }
247        };
248    }
249
250    fn brushing_completed_mutation(&self, state: &mut BrushableState) -> BrushableWorldMutation {
251        let mut mutation = BrushableWorldMutation {
252            completed_level_event_data: Some(i32::from(self.get_block_state().0)),
253            drop: self.take_drop_content(state),
254            set_block: None,
255        };
256
257        let turns_into = BLOCK_BEHAVIORS
258            .get_behavior_for_state(self.get_block_state())
259            .and_then(|behavior| behavior.brushable_data(self.get_block_state()))
260            .map_or(vanilla_blocks::AIR.default_state(), |data| {
261                data.turns_into.default_state()
262            });
263        mutation.set_block = Some(turns_into);
264        mutation
265    }
266
267    fn take_drop_content(&self, state: &mut BrushableState) -> Option<(DVec3, ItemStack)> {
268        if state.item.is_empty() {
269            return None;
270        }
271
272        let direction = state.hit_direction.unwrap_or(Direction::Up);
273        let drop_pos = direction.relative(self.get_block_pos());
274        let count = rand::random_range(10..=30).min(state.item.count());
275        let dropped = state.item.split(count);
276        state.item = ItemStack::empty();
277        let size = f64::from(vanilla_entities::ITEM.dimensions.width);
278        let center_range = 1.0 - size;
279        let half_size = size / 2.0;
280        let item_height = f64::from(vanilla_entities::ITEM.dimensions.height);
281        let pos = DVec3::new(
282            f64::from(drop_pos.x()) + 0.5 * center_range + half_size,
283            f64::from(drop_pos.y()) + 0.5 + item_height / 2.0,
284            f64::from(drop_pos.z()) + 0.5 * center_range + half_size,
285        );
286        Some((pos, dropped))
287    }
288
289    fn with_dusted(&self, completion_state: i32) -> BlockStateId {
290        self.get_block_state()
291            .set_value(&BlockStateProperties::DUSTED, completion_state as u8)
292    }
293}
294
295impl BrushableState {
296    const fn completion_state(&self) -> i32 {
297        match self.brush_count {
298            0 => 0,
299            1..=2 => 1,
300            3..=5 => 2,
301            _ => 3,
302        }
303    }
304
305    /// Client-only fields (`getUpdateTag`): hit direction byte + optional item.
306    fn save_client_data(&self, nbt: &mut NbtCompound) {
307        if let Some(direction) = self.hit_direction {
308            nbt.insert("hit_direction", direction.get_3d_data_value() as i8);
309        }
310        if !self.item.is_empty() {
311            nbt.insert("item", self.item.to_nbt_tag_ref());
312        }
313    }
314}
315
316impl BlockEntity for BrushableBlockEntity {
317    fn base(&self) -> &BlockEntityBase {
318        &self.base
319    }
320
321    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
322        let nbt_view: NbtCompoundView<'_, '_> = nbt.into();
323        let mut state = self.state.lock();
324
325        state.loot_table = nbt_view
326            .string("LootTable")
327            .and_then(|value| Identifier::from_str(&value.to_str()).ok());
328        state.loot_table_seed = nbt_view.long("LootTableSeed").unwrap_or(0);
329
330        // Vanilla: LootTable and item are mutually exclusive on load.
331        if state.loot_table.is_some() {
332            state.item = ItemStack::empty();
333        } else {
334            state.item = nbt_view
335                .compound("item")
336                .and_then(|compound| ItemStack::from_borrowed_compound(&compound))
337                .unwrap_or_else(ItemStack::empty);
338        }
339
340        // Vanilla Direction.LEGACY_ID_CODEC: byte 3D data value.
341        state.hit_direction = nbt_view
342            .byte("hit_direction")
343            .map(|value| Direction::from_3d_data_value(i32::from(value)));
344    }
345
346    fn save_additional(&self, nbt: &mut NbtCompound) {
347        let state = self.state.lock();
348        // Vanilla trySaveLootTable: if loot is present, skip item and never write hit_direction.
349        if let Some(loot_table) = &state.loot_table {
350            nbt.insert("LootTable", loot_table.to_string());
351            if state.loot_table_seed != 0 {
352                nbt.insert("LootTableSeed", state.loot_table_seed);
353            }
354            return;
355        }
356
357        if !state.item.is_empty() {
358            nbt.insert("item", state.item.to_nbt_tag_ref());
359        }
360    }
361
362    fn get_update_tag(&self) -> Option<NbtCompound> {
363        let mut nbt = NbtCompound::new();
364        self.state.lock().save_client_data(&mut nbt);
365        Some(nbt)
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use std::io::Cursor;
372    use std::sync::Weak;
373
374    use simdnbt::borrow::read_compound as read_borrowed_compound;
375    use steel_registry::vanilla_items;
376    use steel_registry::{init_vanilla_registry, vanilla_blocks};
377
378    use super::*;
379
380    fn load_from_owned_nbt(entity: &mut BrushableBlockEntity, nbt: &NbtCompound) {
381        let mut bytes = Vec::new();
382        nbt.write(&mut bytes);
383        let borrowed = read_borrowed_compound(&mut Cursor::new(bytes.as_slice()))
384            .expect("test nbt should reborrow");
385        entity.load_additional(&borrowed);
386    }
387
388    fn brushable() -> BrushableBlockEntity {
389        init_vanilla_registry();
390        BrushableBlockEntity::new(
391            Weak::new(),
392            BlockPos::new(1, 64, 2),
393            vanilla_blocks::SUSPICIOUS_SAND.default_state(),
394        )
395    }
396
397    #[test]
398    fn save_loot_table_excludes_item_and_hit_direction() {
399        let mut entity = brushable();
400        let mut nbt = NbtCompound::new();
401        nbt.insert("LootTable", "minecraft:archaeology/desert_pyramid");
402        nbt.insert("LootTableSeed", 42_i64);
403        nbt.insert("hit_direction", Direction::North.get_3d_data_value() as i8);
404        nbt.insert(
405            "item",
406            ItemStack::new(&vanilla_items::STICK).to_nbt_tag_ref(),
407        );
408        load_from_owned_nbt(&mut entity, &nbt);
409
410        let mut saved = NbtCompound::new();
411        entity.save_additional(&mut saved);
412
413        assert_eq!(
414            saved.string("LootTable").map(ToString::to_string),
415            Some("minecraft:archaeology/desert_pyramid".to_owned())
416        );
417        assert_eq!(saved.long("LootTableSeed"), Some(42));
418        assert!(saved.compound("item").is_none());
419        assert!(saved.byte("hit_direction").is_none());
420    }
421
422    #[test]
423    fn save_loot_table_omits_zero_seed() {
424        let mut entity = brushable();
425        let mut nbt = NbtCompound::new();
426        nbt.insert("LootTable", "minecraft:archaeology/ocean_ruin_warm");
427        nbt.insert("LootTableSeed", 0_i64);
428        load_from_owned_nbt(&mut entity, &nbt);
429
430        let mut saved = NbtCompound::new();
431        entity.save_additional(&mut saved);
432
433        assert_eq!(
434            saved.string("LootTable").map(ToString::to_string),
435            Some("minecraft:archaeology/ocean_ruin_warm".to_owned())
436        );
437        assert!(saved.long("LootTableSeed").is_none());
438    }
439
440    #[test]
441    fn save_item_only_when_no_loot_table() {
442        let mut entity = brushable();
443        let mut nbt = NbtCompound::new();
444        nbt.insert(
445            "item",
446            ItemStack::new(&vanilla_items::STICK).to_nbt_tag_ref(),
447        );
448        load_from_owned_nbt(&mut entity, &nbt);
449
450        let mut saved = NbtCompound::new();
451        entity.save_additional(&mut saved);
452
453        assert!(saved.string("LootTable").is_none());
454        assert!(saved.compound("item").is_some());
455    }
456
457    #[test]
458    fn load_loot_table_discards_item() {
459        let mut entity = brushable();
460        let mut nbt = NbtCompound::new();
461        nbt.insert("LootTable", "minecraft:archaeology/desert_pyramid");
462        nbt.insert(
463            "item",
464            ItemStack::new(&vanilla_items::STICK).to_nbt_tag_ref(),
465        );
466        load_from_owned_nbt(&mut entity, &nbt);
467
468        let mut saved = NbtCompound::new();
469        entity.save_additional(&mut saved);
470
471        assert!(saved.string("LootTable").is_some());
472        assert!(saved.compound("item").is_none());
473    }
474
475    #[test]
476    fn load_item_when_no_loot_table() {
477        let mut entity = brushable();
478        let mut nbt = NbtCompound::new();
479        nbt.insert(
480            "item",
481            ItemStack::with_count(&vanilla_items::STICK, 3).to_nbt_tag_ref(),
482        );
483        load_from_owned_nbt(&mut entity, &nbt);
484
485        {
486            let state = entity.state.lock();
487            assert_eq!(state.item.count(), 3);
488            assert!(state.item.is(&vanilla_items::STICK));
489        }
490
491        let mut saved = NbtCompound::new();
492        entity.save_additional(&mut saved);
493        assert!(saved.compound("item").is_some());
494        assert!(saved.string("LootTable").is_none());
495    }
496
497    #[test]
498    fn hit_direction_is_byte_on_update_tag_not_disk() {
499        let mut entity = brushable();
500        let mut nbt = NbtCompound::new();
501        nbt.insert("hit_direction", Direction::North.get_3d_data_value() as i8);
502        nbt.insert(
503            "item",
504            ItemStack::new(&vanilla_items::STICK).to_nbt_tag_ref(),
505        );
506        load_from_owned_nbt(&mut entity, &nbt);
507
508        let mut disk = NbtCompound::new();
509        entity.save_additional(&mut disk);
510        assert!(disk.byte("hit_direction").is_none());
511        assert!(disk.compound("item").is_some());
512
513        let update = entity.get_update_tag().expect("update tag");
514        assert_eq!(
515            update.byte("hit_direction"),
516            Some(Direction::North.get_3d_data_value() as i8)
517        );
518        assert!(update.compound("item").is_some());
519        assert!(update.string("LootTable").is_none());
520    }
521}