Skip to main content

steel_core/block_entity/entities/
abstract_furnace.rs

1//! Shared furnace, blast-furnace, and smoker block-entity implementation.
2
3use std::array::from_fn;
4use std::mem;
5use std::sync::{Arc, Weak};
6
7use glam::DVec3;
8use rustc_hash::FxHashMap;
9use simdnbt::ToNbtTag as _;
10use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
11use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
12use steel_registry::block_entity_type::BlockEntityTypeRef;
13use steel_registry::blocks::block_state_ext::BlockStateExt as _;
14use steel_registry::blocks::properties::{BlockStateProperties, BoolProperty, Direction};
15use steel_registry::item_stack::ItemStack;
16use steel_registry::recipe::{
17    CachedRecipeCheck, CookingRecipe, RecipeType, SingleItemRecipeInput, TypedRecipeRef,
18    vanilla_recipe_types,
19};
20use steel_registry::{REGISTRY, vanilla_block_entity_types, vanilla_items};
21use steel_utils::locks::IntoShared;
22use steel_utils::nbt::NbtNumeric as _;
23use steel_utils::nbt::NbtValueInput as _;
24use steel_utils::{
25    BlockPos, BlockStateId, DowncastType, DowncastTypeKey, Identifier, locks::SyncMutex,
26    types::UpdateFlags,
27};
28
29use crate::block_entity::{BlockEntity, BlockEntityBase};
30use crate::entity::entities::ExperienceOrbEntity;
31use crate::inventory::container::Container;
32use crate::inventory::container::load_all_items;
33use crate::inventory::fuel_values::VANILLA_FUEL_VALUES;
34use crate::inventory::lock::{ContainerRef, SharedContainer};
35use crate::world::World;
36
37pub const FURNACE_SLOTS: usize = 3;
38pub const SLOT_INPUT: usize = 0;
39pub const SLOT_FUEL: usize = 1;
40pub const SLOT_RESULT: usize = 2;
41
42const SLOTS_FOR_UP: &[usize] = &[SLOT_INPUT];
43const SLOTS_FOR_DOWN: &[usize] = &[SLOT_RESULT, SLOT_FUEL];
44const SLOTS_FOR_SIDES: &[usize] = &[SLOT_FUEL];
45const DEFAULT_COOKING_TIME: i32 = 200;
46const BURN_COOL_SPEED: i32 = 2;
47const LIT: &BoolProperty = &BlockStateProperties::LIT;
48
49/// The operational differences between Vanilla's three furnace block entities.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FurnaceKind {
52    /// Standard smelting furnace.
53    Furnace,
54    /// Ore and metal blasting furnace.
55    BlastFurnace,
56    /// Food smoking furnace.
57    Smoker,
58}
59
60impl FurnaceKind {
61    /// Returns the recipe type processed by this furnace.
62    #[must_use]
63    pub const fn recipe_type(self) -> &'static RecipeType<CookingRecipe, SingleItemRecipeInput> {
64        match self {
65            Self::Furnace => &vanilla_recipe_types::SMELTING,
66            Self::BlastFurnace => &vanilla_recipe_types::BLASTING,
67            Self::Smoker => &vanilla_recipe_types::SMOKING,
68        }
69    }
70
71    #[must_use]
72    const fn fuel_duration(self, vanilla_duration: i32) -> i32 {
73        match self {
74            Self::Furnace => vanilla_duration,
75            Self::BlastFurnace | Self::Smoker => vanilla_duration / 2,
76        }
77    }
78
79    #[must_use]
80    const fn cooking_time(self, recipe_time: i32) -> i32 {
81        match self {
82            Self::Furnace => recipe_time,
83            Self::BlastFurnace | Self::Smoker => recipe_time / 2,
84        }
85    }
86}
87
88/// Independently lockable furnace inventory and progress data.
89pub struct FurnaceContainer {
90    kind: FurnaceKind,
91    items: [ItemStack; FURNACE_SLOTS],
92    lit_time_remaining: i32,
93    lit_total_time: i32,
94    cooking_timer: i32,
95    cooking_total_time: i32,
96    recipes_used: FxHashMap<Identifier, i32>,
97    quick_check: CachedRecipeCheck<CookingRecipe, SingleItemRecipeInput>,
98}
99
100struct FurnaceTickResult {
101    changed: bool,
102    lit_changed: bool,
103    is_lit: bool,
104}
105
106// SAFETY: This Steel-owned key uniquely identifies furnace inventory/progress storage.
107unsafe impl DowncastType for FurnaceContainer {
108    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:container/abstract_furnace");
109}
110
111impl FurnaceContainer {
112    fn new(kind: FurnaceKind) -> Self {
113        Self {
114            kind,
115            items: from_fn(|_| ItemStack::empty()),
116            lit_time_remaining: 0,
117            lit_total_time: 0,
118            cooking_timer: 0,
119            cooking_total_time: 0,
120            recipes_used: FxHashMap::default(),
121            quick_check: CachedRecipeCheck::new(kind.recipe_type()),
122        }
123    }
124
125    #[must_use]
126    pub(crate) const fn data(&self) -> [i16; 4] {
127        [
128            self.lit_time_remaining as i16,
129            self.lit_total_time as i16,
130            self.cooking_timer as i16,
131            self.cooking_total_time as i16,
132        ]
133    }
134
135    fn recipe_for_input(&mut self) -> Option<TypedRecipeRef<CookingRecipe, SingleItemRecipeInput>> {
136        let input = SingleItemRecipeInput::new(self.items[SLOT_INPUT].clone());
137        self.quick_check.find_match(&REGISTRY.recipes, &input)
138    }
139
140    fn reset_cooking_for_input(&mut self) {
141        let recipe_time = self
142            .recipe_for_input()
143            .map_or(DEFAULT_COOKING_TIME, |recipe| recipe.data().cooking_time);
144        self.cooking_total_time = self.kind.cooking_time(recipe_time);
145        self.cooking_timer = 0;
146    }
147
148    fn can_burn(&self, result: &ItemStack) -> bool {
149        if result.is_empty() {
150            return false;
151        }
152        let current = &self.items[SLOT_RESULT];
153        if current.is_empty() {
154            return true;
155        }
156        if !ItemStack::is_same_item_same_components(current, result) {
157            return false;
158        }
159        current.count() + result.count() <= self.get_max_stack_size_for_item(result)
160    }
161
162    fn consume_fuel(&mut self) {
163        let fuel_item = self.items[SLOT_FUEL].item();
164        self.items[SLOT_FUEL].shrink(1);
165        if self.items[SLOT_FUEL].is_empty() {
166            self.items[SLOT_FUEL] = fuel_item.get_crafting_remainder();
167        }
168    }
169
170    fn burn(&mut self, recipe: TypedRecipeRef<CookingRecipe, SingleItemRecipeInput>) {
171        let result = recipe.data().result.create();
172        if self.items[SLOT_RESULT].is_empty() {
173            self.items[SLOT_RESULT] = result;
174        } else {
175            self.items[SLOT_RESULT].grow(result.count());
176        }
177
178        if self.items[SLOT_INPUT].is(&vanilla_items::WET_SPONGE)
179            && self.items[SLOT_FUEL].is(&vanilla_items::BUCKET)
180        {
181            self.items[SLOT_FUEL] = ItemStack::new(&vanilla_items::WATER_BUCKET);
182        }
183        self.items[SLOT_INPUT].shrink(1);
184        *self.recipes_used.entry(recipe.key().clone()).or_default() += 1;
185    }
186
187    fn tick(&mut self) -> FurnaceTickResult {
188        let was_lit = self.lit_time_remaining > 0;
189        if was_lit {
190            self.lit_time_remaining -= 1;
191        }
192        let mut is_lit = self.lit_time_remaining > 0;
193        let mut changed = false;
194        let has_ingredient = !self.items[SLOT_INPUT].is_empty();
195        let has_fuel = !self.items[SLOT_FUEL].is_empty();
196
197        if is_lit || (has_fuel && has_ingredient) {
198            if let Some(recipe) = self.recipe_for_input() {
199                let result = recipe.data().result.create();
200                if self.can_burn(&result) {
201                    if !is_lit {
202                        let vanilla_duration =
203                            VANILLA_FUEL_VALUES.burn_duration(self.items[SLOT_FUEL].item());
204                        let duration = self.kind.fuel_duration(vanilla_duration);
205                        self.lit_time_remaining = duration;
206                        self.lit_total_time = duration;
207                        if duration > 0 {
208                            self.consume_fuel();
209                            is_lit = true;
210                            changed = true;
211                        }
212                    }
213
214                    if is_lit {
215                        self.cooking_timer += 1;
216                        if self.cooking_timer == self.cooking_total_time {
217                            self.cooking_timer = 0;
218                            self.cooking_total_time =
219                                self.kind.cooking_time(recipe.data().cooking_time);
220                            self.burn(recipe);
221                            changed = true;
222                        }
223                    } else {
224                        self.cooking_timer = 0;
225                    }
226                } else {
227                    self.cooking_timer = 0;
228                }
229            } else {
230                self.cooking_timer = 0;
231            }
232        } else if self.cooking_timer > 0 {
233            self.cooking_timer =
234                (self.cooking_timer - BURN_COOL_SPEED).clamp(0, self.cooking_total_time);
235        }
236
237        FurnaceTickResult {
238            changed,
239            lit_changed: was_lit != is_lit,
240            is_lit,
241        }
242    }
243
244    pub(crate) fn take_recipes_used(&mut self) -> FxHashMap<Identifier, i32> {
245        mem::take(&mut self.recipes_used)
246    }
247}
248
249impl Container for FurnaceContainer {
250    fn items(&self) -> &[ItemStack] {
251        &self.items
252    }
253
254    fn items_mut(&mut self) -> &mut [ItemStack] {
255        &mut self.items
256    }
257
258    fn set_item(&mut self, slot: usize, mut stack: ItemStack) {
259        if slot >= FURNACE_SLOTS {
260            return;
261        }
262        let same_input = slot == SLOT_INPUT
263            && !stack.is_empty()
264            && ItemStack::is_same_item_same_components(&self.items[slot], &stack);
265        let max_size = self.get_max_stack_size_for_item(&stack);
266        if stack.count() > max_size {
267            stack.set_count(max_size);
268        }
269        self.items[slot] = stack;
270        if slot == SLOT_INPUT && !same_input {
271            self.reset_cooking_for_input();
272        }
273    }
274
275    fn get_max_stack_size(&self) -> i32 {
276        64
277    }
278
279    fn set_changed(&mut self) {}
280
281    fn can_place_item(&self, slot: usize, stack: &ItemStack) -> bool {
282        match slot {
283            SLOT_RESULT => false,
284            SLOT_FUEL => {
285                VANILLA_FUEL_VALUES.is_fuel(stack.item())
286                    || (stack.is(&vanilla_items::BUCKET)
287                        && !self.items[SLOT_FUEL].is(&vanilla_items::BUCKET))
288            }
289            _ => true,
290        }
291    }
292
293    fn slots_for_face(&self, direction: Direction) -> Option<&'static [usize]> {
294        Some(match direction {
295            Direction::Down => SLOTS_FOR_DOWN,
296            Direction::Up => SLOTS_FOR_UP,
297            _ => SLOTS_FOR_SIDES,
298        })
299    }
300
301    fn can_take_item_through_face(
302        &self,
303        slot: usize,
304        stack: &ItemStack,
305        direction: Direction,
306    ) -> bool {
307        direction != Direction::Down
308            || slot != SLOT_FUEL
309            || stack.is(&vanilla_items::WATER_BUCKET)
310            || stack.is(&vanilla_items::BUCKET)
311    }
312}
313
314/// Shared implementation owned by one of the three concrete furnace entities.
315pub struct AbstractFurnaceBlockEntity {
316    base: Arc<BlockEntityBase>,
317    container: Arc<SyncMutex<FurnaceContainer>>,
318    container_ref: ContainerRef,
319}
320
321impl AbstractFurnaceBlockEntity {
322    fn new(
323        block_entity_type: BlockEntityTypeRef,
324        kind: FurnaceKind,
325        level: Weak<World>,
326        pos: BlockPos,
327        state: BlockStateId,
328    ) -> Self {
329        let base = Arc::new(BlockEntityBase::new(block_entity_type, level, pos, state));
330        let container = FurnaceContainer::new(kind).into_shared();
331        let shared: SharedContainer = Arc::<SyncMutex<FurnaceContainer>>::clone(&container);
332        Self {
333            container_ref: ContainerRef::owned_by_block_entity(shared, Arc::clone(&base)),
334            base,
335            container,
336        }
337    }
338
339    #[must_use]
340    pub fn container_ref(&self) -> ContainerRef {
341        self.container_ref.clone()
342    }
343
344    fn server_tick(&self, world: &Arc<World>) {
345        let result = self.container.lock().tick();
346
347        if result.lit_changed {
348            let state = self.base.block_state().set_value(LIT, result.is_lit);
349            world.set_block(self.base.pos(), state, UpdateFlags::UPDATE_ALL);
350        }
351        if result.changed || result.lit_changed {
352            self.base.set_changed();
353        }
354    }
355
356    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
357        let nbt: NbtCompoundView<'_, '_> = nbt.into();
358        let mut furnace = self.container.lock();
359        furnace.items.fill(ItemStack::empty());
360        load_all_items(nbt, &mut furnace.items);
361        furnace.cooking_timer = nbt.int_value("cooking_time_spent").unwrap_or(0);
362        furnace.cooking_total_time = nbt.int_value("cooking_total_time").unwrap_or(0);
363        furnace.lit_time_remaining = nbt.int_value("lit_time_remaining").unwrap_or(0);
364        furnace.lit_total_time = nbt.int_value("lit_total_time").unwrap_or(0);
365        furnace.recipes_used.clear();
366        if let Some(recipes) = nbt.compound("RecipesUsed") {
367            for (key, value) in recipes.iter() {
368                let Some(count) = value.codec_i32() else {
369                    continue;
370                };
371                if let Ok(identifier) = key.to_string().parse() {
372                    furnace.recipes_used.insert(identifier, count);
373                }
374            }
375        }
376    }
377
378    fn save_additional(&self, nbt: &mut NbtCompound) {
379        let furnace = self.container.lock();
380        nbt.insert("cooking_time_spent", furnace.cooking_timer);
381        nbt.insert("cooking_total_time", furnace.cooking_total_time);
382        nbt.insert("lit_time_remaining", furnace.lit_time_remaining);
383        nbt.insert("lit_total_time", furnace.lit_total_time);
384
385        let mut items = Vec::new();
386        for (slot, stack) in furnace.items.iter().enumerate() {
387            if !stack.is_empty()
388                && let NbtTag::Compound(mut item) = stack.clone().to_nbt_tag()
389            {
390                item.insert("Slot", slot as i8);
391                items.push(item);
392            }
393        }
394        nbt.insert("Items", NbtList::Compound(items));
395
396        let mut recipes = NbtCompound::new();
397        for (key, count) in &furnace.recipes_used {
398            recipes.insert(key.to_string(), *count);
399        }
400        nbt.insert("RecipesUsed", recipes);
401    }
402
403    fn pre_remove_side_effects(&self, world: &Arc<World>, pos: BlockPos) {
404        let (items, recipes) = {
405            let mut furnace = self.container.lock();
406            let items = mem::replace(&mut furnace.items, from_fn(|_| ItemStack::empty()));
407            (items, furnace.take_recipes_used())
408        };
409        for item in items {
410            world.drop_item_stack(pos, item);
411        }
412        pop_furnace_experience(
413            world,
414            DVec3::new(
415                f64::from(pos.x()) + 0.5,
416                f64::from(pos.y()) + 0.5,
417                f64::from(pos.z()) + 0.5,
418            ),
419            recipes,
420        );
421    }
422}
423
424pub(crate) fn pop_furnace_experience(
425    world: &Arc<World>,
426    position: DVec3,
427    recipes: FxHashMap<Identifier, i32>,
428) {
429    for (key, amount) in recipes {
430        let Some(recipe) = REGISTRY.recipes.by_key(&key) else {
431            continue;
432        };
433        let Some(cooking) = recipe.downcast_data::<CookingRecipe>() else {
434            continue;
435        };
436        let exact = amount as f32 * cooking.experience;
437        let mut reward = exact.floor() as i32;
438        if exact.fract() != 0.0 && rand::random::<f32>() < exact.fract() {
439            reward += 1;
440        }
441        ExperienceOrbEntity::award(world, position, reward);
442    }
443}
444
445macro_rules! furnace_block_entity {
446    ($name:ident, $key:literal, $type:ident, $kind:ident) => {
447        #[doc = concat!(
448                                            "`",
449                                            stringify!($name),
450                                            "` block entity backed by the shared smelting logic."
451                                        )]
452        pub struct $name {
453            common: AbstractFurnaceBlockEntity,
454        }
455
456        // SAFETY: This Steel-owned key uniquely identifies this concrete block entity.
457        unsafe impl DowncastType for $name {
458            const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new($key);
459        }
460
461        impl $name {
462            /// Creates the block entity at a live world position.
463            #[must_use]
464            pub fn new(level: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
465                Self {
466                    common: AbstractFurnaceBlockEntity::new(
467                        &vanilla_block_entity_types::$type,
468                        FurnaceKind::$kind,
469                        level,
470                        pos,
471                        state,
472                    ),
473                }
474            }
475        }
476
477        impl BlockEntity for $name {
478            fn base(&self) -> &BlockEntityBase {
479                &self.common.base
480            }
481
482            fn pre_remove_side_effects(&self, pos: BlockPos, _state: BlockStateId) {
483                if let Some(world) = self.get_level() {
484                    self.common.pre_remove_side_effects(&world, pos);
485                }
486            }
487
488            fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
489                self.common.load_additional(nbt);
490            }
491
492            fn save_additional(&self, nbt: &mut NbtCompound) {
493                self.common.save_additional(nbt);
494            }
495
496            fn tick(&self, world: &Arc<World>) {
497                self.common.server_tick(world);
498            }
499
500            fn container_ref(&self) -> Option<ContainerRef> {
501                Some(self.common.container_ref())
502            }
503        }
504    };
505}
506
507furnace_block_entity!(
508    FurnaceBlockEntity,
509    "steel:block_entity/furnace",
510    FURNACE,
511    Furnace
512);
513furnace_block_entity!(
514    BlastFurnaceBlockEntity,
515    "steel:block_entity/blast_furnace",
516    BLAST_FURNACE,
517    BlastFurnace
518);
519furnace_block_entity!(
520    SmokerBlockEntity,
521    "steel:block_entity/smoker",
522    SMOKER,
523    Smoker
524);
525
526#[cfg(test)]
527mod tests {
528    use steel_registry::{init_vanilla_registry, vanilla_items};
529
530    use super::*;
531
532    fn cook_iron(kind: FurnaceKind, expected_ticks: usize, expected_fuel_time: i32) {
533        init_vanilla_registry();
534        let mut furnace = FurnaceContainer::new(kind);
535        furnace.set_item(SLOT_INPUT, ItemStack::new(&vanilla_items::IRON_ORE));
536        furnace.set_item(SLOT_FUEL, ItemStack::new(&vanilla_items::COAL));
537
538        for _ in 0..expected_ticks {
539            furnace.tick();
540        }
541
542        assert!(furnace.items[SLOT_INPUT].is_empty());
543        assert!(furnace.items[SLOT_FUEL].is_empty());
544        assert!(furnace.items[SLOT_RESULT].is(&vanilla_items::IRON_INGOT));
545        assert_eq!(furnace.items[SLOT_RESULT].count(), 1);
546        assert_eq!(furnace.lit_total_time, expected_fuel_time);
547        assert_eq!(furnace.recipes_used.values().sum::<i32>(), 1);
548    }
549
550    #[test]
551    fn furnace_consumes_one_fuel_and_finishes_at_recipe_cooking_time() {
552        cook_iron(FurnaceKind::Furnace, 200, 1600);
553    }
554
555    #[test]
556    fn blast_furnace_uses_blasting_time_and_half_fuel_duration() {
557        cook_iron(FurnaceKind::BlastFurnace, 100, 800);
558    }
559
560    #[test]
561    fn furnace_sided_inventory_matches_vanilla_faces_and_bucket_rule() {
562        init_vanilla_registry();
563        let furnace = FurnaceContainer::new(FurnaceKind::Furnace);
564
565        assert_eq!(
566            furnace.slots_for_face(Direction::Up),
567            Some(&[SLOT_INPUT][..])
568        );
569        assert_eq!(
570            furnace.slots_for_face(Direction::Down),
571            Some(&[SLOT_RESULT, SLOT_FUEL][..])
572        );
573        assert_eq!(
574            furnace.slots_for_face(Direction::North),
575            Some(&[SLOT_FUEL][..])
576        );
577        assert!(!furnace.can_take_item_through_face(
578            SLOT_FUEL,
579            &ItemStack::new(&vanilla_items::COAL),
580            Direction::Down,
581        ));
582        assert!(furnace.can_take_item_through_face(
583            SLOT_FUEL,
584            &ItemStack::new(&vanilla_items::WATER_BUCKET),
585            Direction::Down,
586        ));
587    }
588}