Furnace: pay the experience a smelt earned

RecipesUsed was written as an empty compound and never read, so a furnace
paid nothing when its output was taken and nothing when it was broken.

- Furnace keeps the per-recipe counter, saves and loads it, and floors each
  recipe on its own as AbstractFurnaceBlockEntity.createExperience does.
- FurnaceResultSlot.checkTakeAchievements pays on a take: the counter is
  read and cleared when the output slot loses an item.
- AbstractFurnaceBlockEntity.playerDestroy pays the same counter as orbs at
  the block when it is broken, whatever the tool.

Fixes #1732

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
This commit is contained in:
Alexander KrylovandHermes Agent committed 2026-10-25 18:28:57 +03:00
1 parent 1a91727eee
commit bf81ae6358
6 files changed
+142 -1

No files matched your search

@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
cooking_timer: menu.data[2],
cooking_total: menu.data[3],
speed: 1.0,
recipes_used: Default::default(),
});
menu.drops.clear();
menu.equipped.clear();
+47
View File
@@ -44,6 +44,10 @@ pub struct Furnace {
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -190,6 +194,7 @@ impl BlockEntity {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
@@ -483,6 +488,26 @@ impl Furnace {
self.cooking_timer = 0;
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: each recipe's own share is floored,
/// then the floors are added. Flooring the sum instead pays one point for two recipes
/// worth 0.6 each, which vanilla does not.
fn owed(used: &HashMap<String, u32>, experience: impl Fn(&str) -> f32) -> u32 {
used.iter()
.map(|(id, count)| (experience(id) * *count as f32).floor().max(0.0) as u32)
.sum()
}
/// What this furnace owes right now, without forgetting it: breaking the block reads
/// the same counter, and only a payout clears it.
pub fn experience_owed(&self, recipes: &Recipes) -> u32 {
Self::owed(&self.recipes_used, |id| recipes.cooking_experience(id))
}
/// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: the experience this
/// furnace owes, forgotten as it is paid.
pub fn take_experience(&mut self, recipes: &Recipes) -> u32 {
let owed = self.experience_owed(recipes);
self.recipes_used.clear();
owed
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
@@ -564,6 +589,7 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -594,3 +620,24 @@ fn total_time(time: u32, speed: f32) -> i32 {
time as i32
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `createExperience` floors each recipe on its own. Two recipes worth 0.6 each pay
/// nothing, where flooring their sum would pay one point.
#[test]
fn experience_is_floored_per_recipe_and_not_on_the_sum() {
let mut used = HashMap::new();
used.insert("a".to_owned(), 1);
used.insert("b".to_owned(), 1);
assert_eq!(Furnace::owed(&used, |_| 0.6), 0);
used.insert("c".to_owned(), 10);
assert_eq!(
Furnace::owed(&used, |id| if id == "c" { 0.1 } else { 0.0 }),
1
);
}
}
+8
View File
@@ -1610,6 +1610,14 @@ impl Recipes {
}
out
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
pub fn cooking_experience(&self, id: &str) -> f32 {
self.cooking
.iter()
.find(|c| c.id == id)
.map_or(0.0, |c| c.experience)
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
+51
View File
@@ -3246,6 +3246,12 @@ impl Game<'_> {
}
// The block entity as it was, for what the drops copy from it.
let key = (pos[0], pos[1], pos[2]);
// AbstractFurnaceBlockEntity.playerDestroy: the experience its recipes earned drops
// with the block. Read now, paid only if the block is really removed.
let furnace_xp = match self.world.block_entities.get(&key) {
Some(BlockEntity::Furnace(f)) => f.experience_owed(self.res.recipes()),
_ => 0,
};
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let name = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &facts.name, name);
@@ -3315,6 +3321,18 @@ impl Game<'_> {
);
}
}
// Stored recipe experience is not a loot drop: it leaves with the block
// whatever the tool, as `AbstractFurnaceBlockEntity` does.
if furnace_xp > 0 {
self.spawn_orbs(
[
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
],
furnace_xp,
);
}
}
self.pending.clear(pos);
self.process_level_events()?;
@@ -5936,6 +5954,12 @@ impl Game<'_> {
// Attached after the slots were set: setting them is not a click.
menu.merchant = session.map(|(s, _)| s);
}
// `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot.
let furnace_before = if matches!(kind, Kind::Furnace(_)) {
menu.slot(2).cloned().map_or(0, |s| s.count)
} else {
0
};
let outcome = run(&mut menu, &env, &to_menu);
let made = maps.made.take();
match outcome {
@@ -5953,6 +5977,12 @@ impl Game<'_> {
self.merchant_sync_out(s);
}
let after: Vec<Option<ItemStack>> = (0..total).map(|i| menu.slot(i).cloned()).collect();
// Only a furnace has a result slot to compare: an enchanting table has two slots.
let furnace_after = if matches!(kind, Kind::Furnace(_)) {
after[2].as_ref().map_or(0, |s| s.count)
} else {
0
};
let carried = menu.carried.take();
let dropped = std::mem::take(&mut menu.dropped);
let sounds = std::mem::take(&mut menu.sounds);
@@ -5977,6 +6007,15 @@ impl Game<'_> {
self.player_drop(stack);
}
self.award_crafted(&crafted);
// shortcut: paid straight to the player instead of through `ExperienceOrb.award`
// (no orb flies at them), which a furnace's orbs reach within a tick anyway;
// upgrade if the visual matters or another block pays out far from the player.
if furnace_before > furnace_after {
let owed = self.take_furnace_experience();
if owed > 0 {
self.give_orb_points(owed);
}
}
}
if kind == Kind::Loom {
if let Some(s) = self.station.as_mut() {
@@ -8768,6 +8807,18 @@ impl Game<'_> {
self.give_orb_points(gained);
}
}
/// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: what the open furnace
/// owes for everything it made since the last payout, cleared as it is read.
fn take_furnace_experience(&mut self) -> u32 {
let Some(pos) = self.container else {
return 0;
};
let recipes = self.res.recipes();
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return 0;
};
f.take_experience(recipes)
}
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
+1
View File
@@ -4919,6 +4919,7 @@ impl Game<'_> {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: std::collections::HashMap::new(),
}))
}
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
+34 -1
View File
@@ -488,7 +488,12 @@ pub fn encode_block_entity(
entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer)));
entries.push(("cooking_total_time", Tag::Int(f.cooking_total)));
entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits())));
entries.push(("RecipesUsed", Tag::Compound(Compound::new())));
let used: Compound = f
.recipes_used
.iter()
.map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32)))
.collect();
entries.push(("RecipesUsed", Tag::Compound(used)));
}
BlockEntity::Brewing(b) => {
entries.push(("Items", encode_items(&b.items)));
@@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
if let Some(speed) = float(tag.get("speed_multiplier")) {
f.speed = speed as f32;
}
// A save without it owes nothing: the field is newer than some worlds.
if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") {
for (id, count) in used {
let count = match count {
Tag::Int(n) => *n,
_ => continue,
};
f.recipes_used
.insert(id.to_string_lossy(), count.max(0) as u32);
}
}
}
BlockEntity::Brewing(b) => {
let items = decode_items(tag.get("Items"), 5);
@@ -4276,4 +4292,21 @@ mod tests {
.unwrap_or(0);
assert_eq!(flying_survival, 0);
}
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
/// player nothing after a reload.
#[test]
fn a_furnaces_recipes_used_survive_a_save() {
use crate::containers::BlockEntity;
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
panic!("a furnace");
};
f.recipes_used
.insert("minecraft:cooked_porkchop".to_owned(), 3);
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
panic!("a furnace back");
};
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
}
}