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