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