1use 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#[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
62pub struct BrushOutcome {
64 pub durability_damage: bool,
65 pub mutation: BrushableWorldMutation,
66}
67
68pub 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
84unsafe impl DowncastType for BrushableBlockEntity {
86 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/brushable");
87}
88
89impl BrushableBlockEntity {
90 #[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 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 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 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 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 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 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}