1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FurnaceKind {
52 Furnace,
54 BlastFurnace,
56 Smoker,
58}
59
60impl FurnaceKind {
61 #[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
88pub 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
106unsafe 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(®ISTRY.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
314pub 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 unsafe impl DowncastType for $name {
458 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new($key);
459 }
460
461 impl $name {
462 #[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}