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::{
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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum FurnaceKind {
49 Furnace,
51 BlastFurnace,
53 Smoker,
55}
56
57impl FurnaceKind {
58 #[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
77pub 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
95unsafe 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(®ISTRY.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
301pub 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 unsafe impl DowncastType for $name {
452 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new($key);
453 }
454
455 impl $name {
456 #[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}