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