mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user