Files
Pumpkin/crates/pumpkin/src/entity/passive/villager/mod.rs
Demetrius Kanios 24ae9faa4f chore(bedrock): Update most of the packets to mirror the official docs (#3044)
* chore(bedrock): Update most of the packets to mirror the official docs
more closely.

* Fix typo

* Fix CCreativeContent

* Undo unrelated `registry.rs` change

* Update WIT to merged PR

---------

Signed-off-by: Demetrius Kanios <demetrius@kanios.net>
2026-08-25 09:06:23 -07:00

2288 lines
87 KiB
Rust

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
use std::sync::{Arc, Weak};
use uuid::Uuid;
use crate::block::blocks::bed::BedBlock;
use pumpkin_data::Enchantment;
use pumpkin_data::attributes::Attributes;
use pumpkin_data::block_properties::{
BedPart, BlockProperties, WhiteBedLikeProperties as BedProperties,
};
use pumpkin_data::effect::StatusEffect;
use pumpkin_data::entity::{EntityPose, EntityType};
use pumpkin_data::item::{Item, JavaToBedrockItemMapping};
use pumpkin_data::item_stack::ItemStack;
use pumpkin_data::potion::Effect;
use pumpkin_data::tag::{Enchantment as EnchantmentTag, Taggable};
use pumpkin_data::tracked_data;
use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler;
use pumpkin_inventory::screen_handler::{
BoxFuture, InventoryPlayer, ScreenHandlerFactory, SharedScreenHandler,
};
use pumpkin_nbt::compound::NbtCompound;
use pumpkin_protocol::bedrock::{
client::set_actor_data::{MetadataValue, SyncedActorDataList, entity_data_key},
server::actor_event::ActorEventID,
};
use pumpkin_protocol::codec::var_int::VarInt;
use pumpkin_protocol::java::client::play::{CMerchantOffers, Metadata};
use pumpkin_util::math::{boundingbox::BoundingBox, position::BlockPos, vector3::Vector3};
use pumpkin_util::text::TextComponent;
use pumpkin_util::version::JavaMinecraftVersion;
use pumpkin_world::inventory::SimpleInventory;
use tokio::sync::Mutex;
use crate::entity::player::Player;
use crate::entity::{
Entity, EntityBase,
ai::{
goal::{
avoid_entity::AvoidEntityGoal, look_around::RandomLookAroundGoal,
look_at_entity::LookAtEntityGoal, open_door::OpenDoorGoal, swim::SwimGoal,
trade_with_player::TradeWithPlayerGoal, wander_around::WanderAroundGoal,
work_at_job_site::WorkAtJobSiteGoal,
},
pathfinder::Navigator,
},
experience_orb::ExperienceOrbEntity,
mob::{Mob, MobEntity},
};
use crate::world::World;
use crate::world::villager_poi::profession_for_block;
pub mod data;
pub use data::{
BREEDING_FOOD_THRESHOLD, GossipType, VillagerData, VillagerProfession, VillagerType,
get_food_points,
};
pub(crate) async fn trigger_trade_advancement(player: &Player) {
player
.trigger_advancement(
crate::entity::player::advancement::trigger::AdvancementTrigger::TradedWithVillager,
)
.await;
}
fn enchanted_book_offer_items(
rng: &mut impl rand::Rng,
) -> Option<(ItemStack, ItemStack, Option<ItemStack>)> {
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::StoredEnchantmentsImpl;
use rand::{RngExt, seq::IndexedRandom};
use std::borrow::Cow;
let enchantment_id = *EnchantmentTag::MINECRAFT_TRADEABLE.1.choose(rng)? as u8;
let enchantment = Enchantment::from_id(enchantment_id)?;
let level = rng.random_range(1..=enchantment.max_level);
let mut emeralds = 2 + rng.random_range(0..5 + level * 10) + 3 * level;
if enchantment.has_tag(&EnchantmentTag::MINECRAFT_DOUBLE_TRADE_PRICE) {
emeralds *= 2;
}
let output = ItemStack::new_with_component(
1,
&Item::ENCHANTED_BOOK,
vec![(
DataComponent::StoredEnchantments,
Some(Box::new(StoredEnchantmentsImpl {
enchantment: Cow::Owned(vec![(enchantment, level)]),
})),
)],
);
Some((
ItemStack::new(emeralds.min(64) as u8, &Item::EMERALD),
output,
Some(ItemStack::new(1, &Item::BOOK)),
))
}
fn enchant_trade_item(
rng: &mut impl rand::Rng,
item: &'static Item,
min_level: i32,
max_level: i32,
) -> Option<(ItemStack, i32)> {
use pumpkin_data::data_component_impl::EnchantableImpl;
use rand::RngExt;
let mut stack = ItemStack::new(1, item);
let enchantability = stack
.get_data_component::<EnchantableImpl>()
.map_or(1, |value| value.value);
let additional_cost = rng.random_range(min_level..=max_level);
let mut level = additional_cost
+ 1
+ rng.random_range(0..=enchantability / 4)
+ rng.random_range(0..=enchantability / 4);
let bonus = (rng.random::<f32>() + rng.random::<f32>() - 1.0) * 0.15;
level = ((level as f32 + level as f32 * bonus).round() as i32).max(1);
let mut available = EnchantmentTag::MINECRAFT_ON_TRADED_EQUIPMENT
.1
.iter()
.filter_map(|id| Enchantment::from_id(*id as u8))
.filter(|enchantment| enchantment.can_enchant(item))
.filter_map(|enchantment| {
(1..=enchantment.max_level)
.rev()
.find(|candidate_level| {
(enchantment.min_cost.calculate(*candidate_level)
..=enchantment.max_cost.calculate(*candidate_level))
.contains(&level)
})
.map(|candidate_level| (enchantment, candidate_level))
})
.collect::<Vec<_>>();
if available.is_empty() {
return None;
}
while !available.is_empty() {
let total_weight: i32 = available
.iter()
.map(|(enchantment, _)| enchantment.weight)
.sum();
let mut choice = rng.random_range(0..total_weight);
let chosen_index = available
.iter()
.position(|(enchantment, _)| {
choice -= enchantment.weight;
choice < 0
})
.unwrap_or(0);
let (enchantment, enchantment_level) = available[chosen_index];
stack.enchant(enchantment, enchantment_level);
if rng.random_range(0..50) > level {
break;
}
available.retain(|(candidate, _)| candidate.are_compatible(enchantment));
level /= 2;
}
Some((stack, additional_cost))
}
pub(crate) fn apply_random_dye(rng: &mut impl rand::Rng, stack: &mut ItemStack) {
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{DataComponentImpl, DyedColorImpl};
use rand::RngExt;
const COLORS: [i32; 16] = [
0xF9FFFE, 0xF9801D, 0xC74EBD, 0x3AB3DA, 0xFED83D, 0x80C71F, 0xF38BAA, 0x474F52, 0x9D9D97,
0x169C9C, 0x8932B8, 0x3C44AA, 0x835432, 0x5E7C16, 0xB02E26, 0x1D1D21,
];
let dye_count = 1 + i32::from(rng.random_bool(0.75)) + i32::from(rng.random_bool(0.75));
let mut channels = [0; 3];
let mut brightness = 0;
for _ in 0..dye_count {
let color = COLORS[rng.random_range(0..COLORS.len())];
let rgb = [(color >> 16) & 255, (color >> 8) & 255, color & 255];
brightness += rgb[0].max(rgb[1]).max(rgb[2]);
for (total, value) in channels.iter_mut().zip(rgb) {
*total += value;
}
}
let mut rgb = channels.map(|channel| channel / dye_count);
let average_brightness = brightness / dye_count;
let max_channel = rgb[0].max(rgb[1]).max(rgb[2]);
for channel in &mut rgb {
*channel = average_brightness * *channel / max_channel;
}
let color = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
stack.patch.push((
DataComponent::DyedColor,
Some(DyedColorImpl { rgb: color }.to_dyn()),
));
}
pub(crate) fn apply_random_stew_effect(rng: &mut impl rand::Rng, stack: &mut ItemStack) {
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{
DataComponentImpl, SuspiciousStewEffect, SuspiciousStewEffectsImpl,
};
use rand::RngExt;
use std::borrow::Cow;
const EFFECTS: [(&str, i32); 6] = [
("minecraft:night_vision", 100),
("minecraft:jump_boost", 160),
("minecraft:weakness", 140),
("minecraft:blindness", 120),
("minecraft:poison", 280),
("minecraft:saturation", 7),
];
let (effect, duration) = EFFECTS[rng.random_range(0..EFFECTS.len())];
stack.patch.push((
DataComponent::SuspiciousStewEffects,
Some(
SuspiciousStewEffectsImpl {
effects: Cow::Owned(vec![SuspiciousStewEffect {
effect: Cow::Owned(effect.to_owned()),
duration,
}]),
}
.to_dyn(),
),
));
}
pub(crate) fn apply_potion(stack: &mut ItemStack, potion_name: &str) {
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{DataComponentImpl, PotionContentsImpl};
let Some(potion) = pumpkin_data::potion::Potion::from_name(
potion_name
.strip_prefix("minecraft:")
.unwrap_or(potion_name),
) else {
return;
};
stack.patch.push((
DataComponent::PotionContents,
Some(
PotionContentsImpl {
potion_id: Some(i32::from(potion.id)),
custom_color: None,
custom_effects: Vec::new(),
custom_name: None,
}
.to_dyn(),
),
));
}
pub struct VillagerEntity {
pub mob_entity: MobEntity,
pub villager_data: Mutex<VillagerData>,
pub food_level: AtomicI32,
pub xp: AtomicI32,
pub last_restock_time: AtomicI64,
pub last_restock_check_day: AtomicI64,
pub last_worked_at_poi: AtomicI64,
pub restocks_today: AtomicI32,
pub last_gossip_decay_time: AtomicI64,
pub gossips: Mutex<HashMap<Uuid, HashMap<GossipType, i32>>>,
pub inventory: Arc<Mutex<Vec<Arc<Mutex<ItemStack>>>>>,
pub merchant_inventory: Arc<SimpleInventory>,
pub offers: Mutex<Vec<pumpkin_protocol::java::client::play::MerchantOffer>>,
pub merchant_update_timer: AtomicI32,
pub unhappy_counter: AtomicI32,
pub trade_sound_cooldown: AtomicI32,
pub increase_profession_level_on_update: AtomicBool,
pub last_traded_player: Mutex<Option<Uuid>>,
pub trading_player: std::sync::Mutex<Option<(Uuid, u8)>>,
pub is_trading: AtomicBool,
pub job_site: std::sync::Mutex<Option<BlockPos>>,
pub job_site_pending: AtomicBool,
pub home_pos: std::sync::Mutex<Option<BlockPos>>,
pub self_weak: std::sync::Mutex<Option<Weak<Self>>>,
}
impl VillagerEntity {
fn bedrock_metadata(data: VillagerData, xp: i32) -> SyncedActorDataList {
const PROFESSIONS: [i32; 15] = [0, 8, 11, 6, 7, 1, 2, 4, 12, 5, 13, 14, 3, 10, 9];
const REGIONS: [i32; 7] = [1, 2, 0, 3, 4, 5, 6];
let mut metadata = SyncedActorDataList::new();
metadata.set(
entity_data_key::VARIANT,
MetadataValue::Int(
usize::try_from(data.profession.0)
.ok()
.and_then(|id| PROFESSIONS.get(id))
.copied()
.unwrap_or_default(),
),
);
metadata.set(
entity_data_key::MARK_VARIANT,
MetadataValue::Int(
usize::try_from(data.r#type.0)
.ok()
.and_then(|id| REGIONS.get(id))
.copied()
.unwrap_or_default(),
),
);
metadata.set(
entity_data_key::TRADE_TIER,
MetadataValue::Int(data.level.0.saturating_sub(1)),
);
metadata.set(entity_data_key::MAX_TRADE_TIER, MetadataValue::Int(4));
metadata.set(entity_data_key::TRADE_EXPERIENCE, MetadataValue::Int(xp));
metadata
}
#[allow(clippy::too_many_lines)]
pub fn new(entity: Entity) -> Arc<Self> {
let mob_entity = MobEntity::new(entity);
let villager_data = VillagerData::new(VillagerType::Plains, VillagerProfession::None, 1);
let inventory = Arc::new(Mutex::new(
(0..8)
.map(|_| Arc::new(Mutex::new(ItemStack::EMPTY.clone())))
.collect(),
));
let villager = Self {
mob_entity,
villager_data: Mutex::new(villager_data),
food_level: AtomicI32::new(0),
xp: AtomicI32::new(0),
last_restock_time: AtomicI64::new(0),
last_restock_check_day: AtomicI64::new(0),
last_worked_at_poi: AtomicI64::new(0),
restocks_today: AtomicI32::new(0),
last_gossip_decay_time: AtomicI64::new(0),
gossips: Mutex::new(HashMap::new()),
inventory,
merchant_inventory: Arc::new(SimpleInventory::new(3)),
offers: Mutex::new(Vec::new()),
merchant_update_timer: AtomicI32::new(0),
unhappy_counter: AtomicI32::new(0),
trade_sound_cooldown: AtomicI32::new(0),
increase_profession_level_on_update: AtomicBool::new(false),
last_traded_player: Mutex::new(None),
trading_player: std::sync::Mutex::new(None),
is_trading: AtomicBool::new(false),
job_site: std::sync::Mutex::new(None),
job_site_pending: AtomicBool::new(false),
home_pos: std::sync::Mutex::new(None),
self_weak: std::sync::Mutex::new(None),
};
let mob_arc = Arc::new(villager);
*mob_arc
.self_weak
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::downgrade(&mob_arc));
let mob_weak: Weak<dyn Mob> = {
let mob_arc: Arc<dyn Mob> = mob_arc.clone();
Arc::downgrade(&mob_arc)
};
{
let mut goal_selector = mob_arc
.mob_entity
.goals_selector
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
goal_selector.add_goal(0, Box::new(SwimGoal::default()));
goal_selector.add_goal(0, Box::new(OpenDoorGoal::new(true)));
// Villagers avoid threats
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::ZOMBIE, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(
&EntityType::ZOMBIE_VILLAGER,
8.0,
0.5,
0.5,
)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::HUSK, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::DROWNED, 8.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::PILLAGER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(
&EntityType::VINDICATOR,
12.0,
0.5,
0.5,
)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::EVOKER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::RAVAGER, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(
1,
Box::new(AvoidEntityGoal::new(&EntityType::VEX, 12.0, 0.5, 0.5)),
);
goal_selector.add_goal(2, Box::new(TradeWithPlayerGoal::new(0.5)));
// Basic movement and looking (Vanilla uses 0.5 speed)
goal_selector.add_goal(3, Box::new(WorkAtJobSiteGoal::new(0.5)));
goal_selector.add_goal(4, Box::new(WanderAroundGoal::new(0.5)));
goal_selector.add_goal(
5,
LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0),
);
goal_selector.add_goal(
6,
LookAtEntityGoal::with_default(mob_weak, &EntityType::VILLAGER, 8.0),
);
goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default()));
};
// Send initial metadata
let bedrock_metadata = Self::bedrock_metadata(villager_data, 0);
mob_arc.get_entity().send_meta_data(
&[Metadata::new(
tracked_data::villager::VILLAGER_DATA,
villager_data,
)],
Some(&bedrock_metadata),
);
mob_arc
}
pub async fn count_food_points_in_inventory(&self) -> i32 {
let inventory = self.inventory.lock().await;
let mut total = 0;
for stack_mutex in inventory.iter() {
let stack = stack_mutex.lock().await;
if !stack.is_empty() {
total += get_food_points(stack.get_item()) * stack.item_count as i32;
}
}
total
}
fn poi_owner(&self) -> Option<Weak<dyn EntityBase>> {
let owner = self
.self_weak
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()?;
let owner: Weak<dyn EntityBase> = owner;
Some(owner)
}
pub async fn eat_until_full(&self) {
if self.food_level.load(Ordering::Relaxed) >= BREEDING_FOOD_THRESHOLD {
return;
}
let inventory = self.inventory.lock().await;
for stack_mutex in inventory.iter() {
let mut stack = stack_mutex.lock().await;
if !stack.is_empty() {
let points = get_food_points(stack.get_item());
if points > 0 {
while stack.item_count > 0
&& self.food_level.load(Ordering::Relaxed) < BREEDING_FOOD_THRESHOLD
{
self.food_level.fetch_add(points, Ordering::Relaxed);
stack.item_count -= 1;
}
if stack.item_count == 0 {
*stack = ItemStack::EMPTY.clone();
}
if self.food_level.load(Ordering::Relaxed) >= BREEDING_FOOD_THRESHOLD {
break;
}
}
}
}
}
pub async fn set_villager_data(&self, data: VillagerData) {
let old_profession = {
let mut villager_data = self.villager_data.lock().await;
let old_profession = villager_data.profession;
*villager_data = data;
old_profession
};
let bedrock_metadata = Self::bedrock_metadata(data, self.xp.load(Ordering::Relaxed));
self.get_entity().send_meta_data(
&[Metadata::new(tracked_data::villager::VILLAGER_DATA, data)],
Some(&bedrock_metadata),
);
if old_profession != data.profession {
self.offers.lock().await.clear();
}
}
#[expect(clippy::too_many_lines)]
async fn create_explorer_map(&self, destination: &str) -> Option<ItemStack> {
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{DataComponentImpl, ItemNameImpl, MapIdImpl};
use pumpkin_data::structures::{StructureKeys, StructureSet};
use pumpkin_world::generation::generator::structure_finder::find_nearest_structure_start;
let (structure_set, structure, name, icon_type) = match destination {
"minecraft:on_jungle_explorer_maps" => (
"jungle_temples",
StructureKeys::JunglePyramid,
"filled_map.explorer_jungle",
32,
),
"minecraft:on_swamp_explorer_maps" => (
"swamp_huts",
StructureKeys::SwampHut,
"filled_map.explorer_swamp",
33,
),
"minecraft:on_desert_village_maps" => (
"villages",
StructureKeys::VillageDesert,
"filled_map.village_desert",
27,
),
"minecraft:on_plains_village_maps" => (
"villages",
StructureKeys::VillagePlains,
"filled_map.village_plains",
28,
),
"minecraft:on_savanna_village_maps" => (
"villages",
StructureKeys::VillageSavanna,
"filled_map.village_savanna",
29,
),
"minecraft:on_snowy_village_maps" => (
"villages",
StructureKeys::VillageSnowy,
"filled_map.village_snowy",
30,
),
"minecraft:on_taiga_village_maps" => (
"villages",
StructureKeys::VillageTaiga,
"filled_map.village_taiga",
31,
),
"minecraft:on_ocean_explorer_maps" => (
"ocean_monuments",
StructureKeys::Monument,
"filled_map.monument",
9,
),
"minecraft:on_trial_chambers_maps" => (
"trial_chambers",
StructureKeys::TrialChambers,
"filled_map.trial_chambers",
34,
),
"minecraft:on_woodland_explorer_maps" => (
"woodland_mansions",
StructureKeys::Mansion,
"filled_map.mansion",
8,
),
_ => return None,
};
let world = self.get_entity().world.load().clone();
let generator = world.level.world_gen();
let target = find_nearest_structure_start(
self.get_entity().block_pos.load(),
StructureSet::get(structure_set)?,
&[structure],
100,
&generator,
)?;
let server = world.server.upgrade()?;
let map_id = server.next_map_id();
let map = server.map_manager.create_map(
map_id,
world.dimension.clone(),
target.0.x,
target.0.z,
2,
);
map.lock()
.await
.decorations
.push(crate::world::map::MapDecoration {
icon_type,
x: 0,
z: 0,
direction: 8,
display_name: None,
});
let mut stack = ItemStack::new(1, &Item::FILLED_MAP);
stack.patch.push((
DataComponent::MapId,
Some(MapIdImpl { id: map_id }.to_dyn()),
));
stack.patch.push((
DataComponent::ItemName,
Some(ItemNameImpl { name: name.into() }.to_dyn()),
));
Some(stack)
}
pub async fn add_trades(&self, profession: VillagerProfession, level: i32) {
use pumpkin_data::villager::VillagerTradeModifier;
use pumpkin_protocol::codec::item_stack_seralizer::ItemStackSerializer;
use rand::seq::IndexedRandom;
use rand::{RngExt, SeedableRng, rngs::StdRng};
use std::borrow::Cow;
let villager_type = self.villager_data.lock().await.type_enum();
let mut offers = self.offers.lock().await;
if let Some(trade_set) = profession.trade_set(level) {
let mut rng = StdRng::from_rng(&mut rand::rng());
let mut remaining_trades = trade_set.trades.iter().collect::<Vec<_>>();
let mut added = 0;
while added < trade_set.amount && !remaining_trades.is_empty() {
let index = rng.random_range(0..remaining_trades.len());
let trade = remaining_trades.remove(index);
if !trade.allowed_types.is_empty() && !trade.allowed_types.contains(&villager_type)
{
continue;
}
let mut base_cost_a = ItemStack::new(trade.wants.count as u8, trade.wants.item);
let mut output = ItemStack::new(trade.gives.count as u8, trade.gives.item);
let mut cost_b = trade
.wants_b
.as_ref()
.map(|b| ItemStack::new(b.count as u8, b.item));
match trade.modifier {
VillagerTradeModifier::None => {}
VillagerTradeModifier::EnchantRandomly => {
let Some(items) = enchanted_book_offer_items(&mut rng) else {
continue;
};
(base_cost_a, output, cost_b) = items;
}
VillagerTradeModifier::EnchantWithLevels { min, max } => {
let Some((enchanted, additional_cost)) =
enchant_trade_item(&mut rng, trade.gives.item, min, max)
else {
continue;
};
output = enchanted;
let count = i32::from(base_cost_a.item_count)
.saturating_add(additional_cost)
.clamp(0, i32::from(base_cost_a.get_max_stack_size()));
if count == 0 {
continue;
}
base_cost_a.set_count(count as u8);
}
VillagerTradeModifier::ExplorationMap { destination } => {
let Some(map) = self.create_explorer_map(destination).await else {
continue;
};
output = map;
}
VillagerTradeModifier::RandomDyes => apply_random_dye(&mut rng, &mut output),
VillagerTradeModifier::RandomPotion => {
let Some(potion_name) = pumpkin_data::tag::Potion::MINECRAFT_TRADEABLE
.0
.choose(&mut rng)
else {
continue;
};
apply_potion(&mut output, potion_name);
}
VillagerTradeModifier::SuspiciousStew => {
apply_random_stew_effect(&mut rng, &mut output);
}
VillagerTradeModifier::Potion(potion) => apply_potion(&mut output, potion),
}
offers.push(pumpkin_protocol::java::client::play::MerchantOffer {
base_cost_a: ItemStackSerializer(Cow::Owned(base_cost_a)),
output: ItemStackSerializer(Cow::Owned(output)),
cost_b: cost_b.map(|stack| ItemStackSerializer(Cow::Owned(stack))),
reward_exp: true,
uses: 0,
max_uses: trade.max_uses,
xp: trade.xp,
special_price: 0,
price_multiplier: trade.price_multiplier,
demand: 0,
});
added += 1;
}
}
}
pub async fn generate_trades(&self, profession: VillagerProfession, level: i32) {
self.offers.lock().await.clear();
self.add_trades(profession, level).await;
}
async fn update_special_prices(&self, player: &Player) {
let player_uuid = player.get_entity().entity_uuid;
let reputation = self
.gossips
.lock()
.await
.get(&player_uuid)
.map_or(0, |gossips| {
gossips
.iter()
.map(|(kind, value)| kind.weight() * value)
.sum()
});
let hero_amplifier = player
.living_entity
.get_effect(&StatusEffect::HERO_OF_THE_VILLAGE)
.await
.map(|effect| i32::from(effect.amplifier));
let mut offers = self.offers.lock().await;
for offer in offers.iter_mut() {
offer.special_price = -((reputation as f32 * offer.price_multiplier).floor() as i32);
if let Some(amplifier) = hero_amplifier {
let discount = ((0.3 + 0.0625 * f64::from(amplifier))
* f64::from(offer.base_cost_a.0.item_count))
.floor() as i32;
offer.special_price -= discount.max(1);
}
}
}
async fn reset_special_prices(&self) {
for offer in self.offers.lock().await.iter_mut() {
offer.special_price = 0;
}
}
fn can_continue_trading(
&self,
inventory_player: &dyn InventoryPlayer,
player_uuid: Uuid,
sync_id: u8,
) -> bool {
let Some(player) = inventory_player.as_any().downcast_ref::<Player>() else {
return false;
};
let entity = self.get_entity();
let range = player
.living_entity
.get_attribute_value(&Attributes::ENTITY_INTERACTION_RANGE)
+ 4.0;
entity.is_alive()
&& self.mob_entity.living_entity.health.load() > 0.0
&& self
.trading_player
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|(uuid, id)| *uuid == player_uuid && *id == sync_id)
&& entity
.bounding_box
.load()
.squared_magnitude(player.eye_position())
< range * range
}
async fn complete_trade(&self, offer_index: usize, world: &Arc<World>, player_uuid: Uuid) {
let (xp_gain, reward_exp) = {
let mut offers = self.offers.lock().await;
let Some(offer) = offers.get_mut(offer_index) else {
return;
};
offer.uses += 1;
(offer.xp, offer.reward_exp)
};
let current_xp = self.xp.fetch_add(xp_gain, Ordering::Relaxed) + xp_gain;
let villager_data = *self.villager_data.lock().await;
let bedrock_metadata = Self::bedrock_metadata(villager_data, current_xp);
self.get_entity().send_meta_data(
&[Metadata::new(
tracked_data::villager::VILLAGER_DATA,
villager_data,
)],
Some(&bedrock_metadata),
);
let mut reward_xp = {
use rand::RngExt;
rand::rng().random_range(3..=6)
};
let current_level = villager_data.level.0;
if current_level < 5 {
let max_xp = match current_level {
1 => 10,
2 => 70,
3 => 150,
4 => 250,
_ => 0,
};
if current_xp >= max_xp {
self.merchant_update_timer.store(40, Ordering::Relaxed);
self.increase_profession_level_on_update
.store(true, Ordering::Relaxed);
reward_xp += 5;
}
}
self.get_entity()
.play_sound(pumpkin_data::sound::Sound::EntityVillagerYes);
self.trade_sound_cooldown.store(20, Ordering::Relaxed);
*self.last_traded_player.lock().await = Some(player_uuid);
if reward_exp {
let position = self.get_entity().pos.load().add_raw(0.0, 0.5, 0.0);
ExperienceOrbEntity::spawn(world, position, reward_xp).await;
}
if let Some(player) = world.get_player_by_uuid(player_uuid) {
trigger_trade_advancement(&player).await;
}
}
async fn resend_offers_to_trading_player(&self) {
let trading_player = *self
.trading_player
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((player_uuid, sync_id)) = trading_player else {
return;
};
let world = self.get_entity().world.load();
let Some(player) = world.get_player_by_uuid(player_uuid) else {
return;
};
let offers = self.offers.lock().await.clone();
let villager_data = *self.villager_data.lock().await;
let screen = player.current_screen_handler.lock().await.clone();
let mut screen = screen.lock().await;
if screen.sync_id() != sync_id {
return;
}
if let Some(handler) = screen.as_any_mut().downcast_mut::<MerchantScreenHandler>() {
handler.offers.clone_from(&offers);
handler.update_result_slot().await;
} else {
return;
}
drop(screen);
self.send_trade_offers(&player, sync_id, offers, villager_data)
.await;
}
async fn decay_gossips(&self, game_time: i64) {
let last_decay = self.last_gossip_decay_time.load(Ordering::Relaxed);
if last_decay == 0 {
self.last_gossip_decay_time
.store(game_time, Ordering::Relaxed);
return;
}
if game_time < last_decay + 24_000 {
return;
}
let mut gossips = self.gossips.lock().await;
for values in gossips.values_mut() {
values.retain(|kind, value| {
*value -= kind.daily_decay();
*value > 0
});
}
gossips.retain(|_, values| !values.is_empty());
self.last_gossip_decay_time
.store(game_time, Ordering::Relaxed);
}
async fn work_at_job_site(&self, game_time: i64, day_time: i64, day: i64) {
use rand::RngExt;
if !(2_000..9_000).contains(&day_time)
|| game_time - self.last_worked_at_poi.load(Ordering::Relaxed) < 300
|| !rand::rng().random_bool(0.5)
{
return;
}
self.last_worked_at_poi.store(game_time, Ordering::Relaxed);
let Some(job_site) = self.get_job_site() else {
return;
};
if job_site
.to_centered_f64()
.squared_distance_to_vec(&self.get_entity().pos.load())
>= 1.73f64.powi(2)
{
return;
}
let profession = self.villager_data.lock().await.profession_enum();
if let Some(sound) = profession.work_sound() {
self.get_entity().play_sound(sound);
}
let last_restock = self.last_restock_time.load(Ordering::Relaxed);
let last_check_day = self.last_restock_check_day.swap(day, Ordering::Relaxed);
if game_time > last_restock + 12_000 || (last_check_day > 0 && day > last_check_day) {
let missed_restock_count = (2 - self.restocks_today.load(Ordering::Relaxed)).max(0);
let mut offers = self.offers.lock().await;
if missed_restock_count > 0 {
for offer in offers.iter_mut() {
offer.reset_uses();
}
}
for _ in 0..missed_restock_count {
for offer in offers.iter_mut() {
offer.update_demand();
}
}
drop(offers);
self.last_restock_time.store(game_time, Ordering::Relaxed);
self.restocks_today.store(0, Ordering::Relaxed);
self.resend_offers_to_trading_player().await;
}
let restocks_today = self.restocks_today.load(Ordering::Relaxed);
let allowed = restocks_today == 0
|| (restocks_today < 2
&& game_time > self.last_restock_time.load(Ordering::Relaxed) + 2_400);
if !allowed {
return;
}
let mut offers = self.offers.lock().await;
if !offers
.iter()
.any(pumpkin_protocol::java::client::play::MerchantOffer::needs_restock)
{
return;
}
for offer in offers.iter_mut() {
offer.update_demand();
offer.reset_uses();
}
self.last_restock_time.store(game_time, Ordering::Relaxed);
self.restocks_today.fetch_add(1, Ordering::Relaxed);
drop(offers);
self.resend_offers_to_trading_player().await;
}
#[expect(clippy::too_many_lines)]
async fn update_job_site(&self, world: &crate::world::World) {
let data = *self.villager_data.lock().await;
let profession = data.profession_enum();
let is_adult = self.get_entity().age.load(Ordering::Relaxed) >= 0;
if profession == VillagerProfession::Nitwit || !is_adult {
if let Some(site) = self.get_job_site() {
world
.villager_poi
.lock()
.await
.release(site, self.get_entity().entity_uuid);
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
self.job_site_pending.store(false, Ordering::Relaxed);
}
return;
}
let Some(owner) = self.poi_owner() else {
return;
};
if let Some(current_site) = self.get_job_site()
&& current_site
.to_centered_f64()
.squared_distance_to_vec(&self.get_entity().pos.load())
< 16.0f64.powi(2)
{
let (block, _state) = world.get_block_and_state(&current_site);
let expected = (profession != VillagerProfession::None).then_some(profession);
let valid = world
.villager_poi
.lock()
.await
.claim(current_site, block, owner.clone(), expected)
.is_some();
if !valid {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
self.job_site_pending.store(false, Ordering::Relaxed);
if self.xp.load(Ordering::Relaxed) == 0
&& data.level.0 <= 1
&& profession != VillagerProfession::None
{
let r#type = self.villager_data.lock().await.type_enum();
self.set_villager_data(VillagerData::new(r#type, VillagerProfession::None, 1))
.await;
}
}
}
if self.get_job_site().is_none() {
let profession = self.villager_data.lock().await.profession_enum();
let expected = (profession != VillagerProfession::None).then_some(profession);
let pos = self.get_entity().block_pos.load();
let start = BlockPos::new(pos.0.x - 10, pos.0.y - 4, pos.0.z - 10);
let end = BlockPos::new(pos.0.x + 10, pos.0.y + 4, pos.0.z + 10);
let mut candidates = Vec::new();
let indexed_sites = world
.villager_poi
.lock()
.await
.available_job_sites(pos, 48, expected);
let saved_sites = world.portal_poi.lock().await.get_in_square(pos, 48, None);
for position in indexed_sites.into_iter().chain(saved_sites) {
let delta = position.0 - pos.0;
if i64::from(delta.x).pow(2) + i64::from(delta.y).pow(2) + i64::from(delta.z).pow(2)
> 48i64.pow(2)
|| candidates
.iter()
.any(|(_, candidate, _, _)| *candidate == position)
{
continue;
}
let (block, _state) = world.get_block_and_state(&position);
if let Some(site_profession) = profession_for_block(block)
&& expected.is_none_or(|profession| profession == site_profession)
{
let distance = position
.to_centered_f64()
.squared_distance_to_vec(&self.get_entity().pos.load());
candidates.push((distance, position, block, site_profession));
}
}
for position in BlockPos::iterate(start, end) {
let (block, _state) = world.get_block_and_state(&position);
let Some(site_profession) = profession_for_block(block) else {
continue;
};
if expected.is_some_and(|profession| profession != site_profession)
|| candidates
.iter()
.any(|(_, candidate, _, _)| *candidate == position)
{
continue;
}
let distance = position
.to_centered_f64()
.squared_distance_to_vec(&self.get_entity().pos.load());
candidates.push((distance, position, block, site_profession));
}
candidates.sort_by(|left, right| left.0.total_cmp(&right.0));
let mut navigator = Navigator::default();
let mut claimed = None;
for (_, position, block, _) in candidates.into_iter().take(5) {
if !navigator
.can_reach_within(
&self.mob_entity.living_entity,
position.to_centered_f64(),
1.73,
)
.await
{
continue;
}
if world
.villager_poi
.lock()
.await
.claim(position, block, owner.clone(), expected)
.is_some()
{
claimed = Some(position);
break;
}
}
if let Some(site) = claimed {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(site);
self.job_site_pending.store(true, Ordering::Relaxed);
}
}
if self.job_site_pending.load(Ordering::Relaxed)
&& let Some(site) = self.get_job_site()
&& site
.to_centered_f64()
.squared_distance_to_vec(&self.get_entity().pos.load())
< 2.0f64.powi(2)
{
let (block, _state) = world.get_block_and_state(&site);
if let Some(claimed_profession) = profession_for_block(block) {
let profession = self.villager_data.lock().await.profession_enum();
if profession != VillagerProfession::None && profession != claimed_profession {
return;
}
world.send_entity_status(
self.get_entity(),
pumpkin_data::entity::EntityStatus::VillagerHappy,
Some(ActorEventID::VillagerHappy),
);
self.job_site_pending.store(false, Ordering::Relaxed);
if profession == VillagerProfession::None {
let r#type = self.villager_data.lock().await.type_enum();
self.set_villager_data(VillagerData::new(r#type, claimed_profession, 1))
.await;
}
}
}
}
pub fn set_unhappy(&self) {
let entity = self.get_entity();
self.unhappy_counter.store(40, Ordering::Relaxed);
entity.send_meta_data(
&[Metadata::new(
tracked_data::villager::UNHAPPY_COUNTER,
VarInt(40),
)],
None,
);
entity.world.load().send_entity_status(
entity,
pumpkin_data::entity::EntityStatus::VillagerAngry,
Some(ActorEventID::VillagerAngry),
);
entity.play_sound(pumpkin_data::sound::Sound::EntityVillagerNo);
}
pub async fn open_trading_screen(&self, player: &Arc<Player>) {
// Open the merchant screen and then send the current offers packet
if let Some(sync_id) = player.open_handled_screen(self, None).await {
let offers = self.offers.lock().await.clone();
let villager_data = *self.villager_data.lock().await;
self.send_trade_offers(player, sync_id, offers, villager_data)
.await;
}
}
fn bedrock_trade_item(stack: &ItemStack, count: u8) -> NbtCompound {
let mut item = NbtCompound::new();
if stack.is_empty() {
return item;
}
let Some(mapping) = JavaToBedrockItemMapping::from_java_item_id(stack.item.id) else {
return item;
};
item.put_byte("Count", count as i8);
item.put_short("Damage", mapping.bedrock_data as i16);
item.put_string("Name", mapping.bedrock_item.registry_key.to_owned());
item
}
fn bedrock_trade_data(
offers: &[pumpkin_protocol::java::client::play::MerchantOffer],
level: i32,
) -> NbtCompound {
use pumpkin_nbt::tag::NbtTag;
let tier = level.saturating_sub(1).max(0);
let mut recipes = Vec::with_capacity(offers.len() + usize::from(level < 5));
for (index, offer) in offers.iter().enumerate() {
let base_cost = &offer.base_cost_a.0;
let demand_bonus = (i32::from(base_cost.item_count).saturating_mul(offer.demand) as f32
* offer.price_multiplier)
.floor()
.max(0.0) as i32;
let adjusted_count = i32::from(base_cost.item_count)
.saturating_add(demand_bonus)
.saturating_add(offer.special_price)
.clamp(1, i32::from(base_cost.get_max_stack_size()))
as u8;
let mut recipe = NbtCompound::new();
recipe.put_int("netId", index as i32 + 1);
recipe.put_int(
"maxUses",
if offer.is_out_of_stock() {
0
} else {
offer.max_uses
},
);
recipe.put_int("traderExp", offer.xp);
recipe.put_float("priceMultiplierA", offer.price_multiplier);
recipe.put_float("priceMultiplierB", 0.0);
recipe.put_compound(
"sell",
Self::bedrock_trade_item(&offer.output.0, offer.output.0.item_count),
);
recipe.put_int("buyCountA", i32::from(base_cost.item_count));
recipe.put_int(
"buyCountB",
offer
.cost_b
.as_ref()
.map_or(0, |cost| i32::from(cost.0.item_count)),
);
recipe.put_int("demand", offer.demand);
recipe.put_int("tier", (index as i32 / 2).min(tier));
recipe.put_compound("buyA", Self::bedrock_trade_item(base_cost, adjusted_count));
recipe.put_compound(
"buyB",
offer.cost_b.as_ref().map_or_else(NbtCompound::new, |cost| {
Self::bedrock_trade_item(&cost.0, cost.0.item_count)
}),
);
recipe.put_int("uses", offer.uses);
recipe.put_byte("rewardExp", i8::from(offer.reward_exp));
recipes.push(NbtTag::Compound(recipe));
}
// Bedrock uses this hidden next-tier entry to render the villager XP bar.
if level < 5 {
let mut recipe = NbtCompound::new();
recipe.put_int("maxUses", 0);
recipe.put_int("traderExp", 0);
recipe.put_float("priceMultiplierA", 0.0);
recipe.put_float("priceMultiplierB", 0.0);
recipe.put_int("buyCountA", 0);
recipe.put_int("buyCountB", 0);
recipe.put_int("demand", 0);
recipe.put_int("tier", 5);
recipe.put_int("uses", 0);
recipe.put_byte("rewardExp", 0);
recipes.push(NbtTag::Compound(recipe));
}
let mut data = NbtCompound::new();
data.put_list("Recipes", recipes);
data.put_list(
"TierExpRequirements",
[0, 10, 70, 150, 250]
.into_iter()
.enumerate()
.map(|(tier, xp)| {
let mut requirement = NbtCompound::new();
requirement.put_int(&tier.to_string(), xp);
NbtTag::Compound(requirement)
})
.collect(),
);
data
}
async fn send_trade_offers(
&self,
player: &Player,
sync_id: u8,
offers: Vec<pumpkin_protocol::java::client::play::MerchantOffer>,
villager_data: VillagerData,
) {
use pumpkin_protocol::{bedrock::client::CUpdateTrade, codec::var_long::VarLong};
let java = CMerchantOffers::new(
VarInt(i32::from(sync_id)),
offers.clone(),
villager_data.level,
VarInt(self.xp.load(Ordering::Relaxed)),
true,
true,
);
let bedrock = CUpdateTrade {
container_id: sync_id,
r#type: 15,
size: VarInt(0),
trader_tier: VarInt(villager_data.level.0.saturating_sub(1)),
entity_unique_id: VarLong(i64::from(self.get_entity().entity_id)),
last_trading_player: VarLong(i64::from(player.entity_id())),
display_name: ScreenHandlerFactory::get_display_name(self).to_pretty_console(),
use_new_trade_screen: true,
using_economy_trade: true,
data: Self::bedrock_trade_data(&offers, villager_data.level.0),
};
player
.client
.enqueue_packet_editioned(&java, &bedrock)
.await;
}
}
impl ScreenHandlerFactory for VillagerEntity {
fn create_screen_handler<'a>(
&'a self,
sync_id: u8,
player_inventory: &'a Arc<pumpkin_inventory::player::player_inventory::PlayerInventory>,
player: &'a dyn InventoryPlayer,
) -> BoxFuture<'a, Option<SharedScreenHandler>> {
Box::pin(async move {
let self_weak = self
.self_weak
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()?;
let server_player = player.as_any().downcast_ref::<Player>();
let player_uuid =
server_player.map_or_else(uuid::Uuid::nil, |p| p.get_entity().entity_uuid);
if let Some(player) = server_player {
self.update_special_prices(player).await;
}
let offers = self.offers.lock().await;
let world = self.get_entity().world.load().clone();
let mut handler = MerchantScreenHandler::new(
sync_id,
player_inventory,
self.merchant_inventory.clone(),
offers.clone(),
)
.await;
self.is_trading.store(true, Ordering::Relaxed);
*self
.trading_player
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((player_uuid, sync_id));
let validity_weak = self_weak.clone();
handler.validity_check = Some(Box::new(move |inventory_player| {
validity_weak.upgrade().is_some_and(|villager| {
villager.can_continue_trading(inventory_player, player_uuid, sync_id)
})
}));
let update_weak = self_weak.clone();
handler.on_trade_updated = Some(Box::new(move |has_result| {
let Some(villager) = update_weak.upgrade() else {
return;
};
if villager
.trade_sound_cooldown
.compare_exchange(0, 20, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
villager.get_entity().play_sound(if has_result {
pumpkin_data::sound::Sound::EntityVillagerYes
} else {
pumpkin_data::sound::Sound::EntityVillagerNo
});
}
}));
let close_weak = self_weak.clone();
handler.on_close = Some(Box::new(move || {
let close_weak = close_weak.clone();
Box::pin(async move {
if let Some(villager) = close_weak.upgrade() {
villager.is_trading.store(false, Ordering::Relaxed);
*villager
.trading_player
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
villager.reset_special_prices().await;
}
})
}));
handler.on_trade = Some(Box::new(move |offer_index| {
let self_weak = self_weak.clone();
let world = world.clone();
Box::pin(async move {
if let Some(villager) = self_weak.upgrade() {
villager
.complete_trade(offer_index, &world, player_uuid)
.await;
}
})
}));
Some(Arc::new(Mutex::new(handler)) as SharedScreenHandler)
})
}
fn get_display_name(&self) -> TextComponent {
let profession = self
.villager_data
.try_lock()
.map_or(VillagerProfession::None, |data| data.profession_enum());
TextComponent::translate(profession.translation_key(), [])
}
}
impl Mob for VillagerEntity {
#[expect(clippy::too_many_lines)]
fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> crate::entity::NbtFuture<'a, ()> {
Box::pin(async move {
let data = self.villager_data.lock().await;
let mut villager_data_nbt = NbtCompound::new();
villager_data_nbt.put_int("Type", data.r#type.0);
villager_data_nbt.put_int("Profession", data.profession.0);
villager_data_nbt.put_int("Level", data.level.0);
nbt.put_compound("VillagerData", villager_data_nbt);
nbt.put_int("FoodLevel", self.food_level.load(Ordering::Relaxed));
nbt.put_int("Xp", self.xp.load(Ordering::Relaxed));
nbt.put_long(
"LastRestock",
self.last_restock_time.load(Ordering::Relaxed),
);
nbt.put_int("RestocksToday", self.restocks_today.load(Ordering::Relaxed));
nbt.put_long(
"LastGossipDecay",
self.last_gossip_decay_time.load(Ordering::Relaxed),
);
let job_site_pos = *self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(pos) = job_site_pos {
nbt.put_int("JobSiteX", pos.0.x);
nbt.put_int("JobSiteY", pos.0.y);
nbt.put_int("JobSiteZ", pos.0.z);
nbt.put_bool(
"JobSitePending",
self.job_site_pending.load(Ordering::Relaxed),
);
}
let home_pos = *self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(pos) = home_pos {
nbt.put_int("HomeX", pos.0.x);
nbt.put_int("HomeY", pos.0.y);
nbt.put_int("HomeZ", pos.0.z);
}
// Save Offers
{
let offers = self.offers.lock().await;
let mut recipes = Vec::new();
for offer in offers.iter() {
let mut recipe = NbtCompound::new();
let mut buy = NbtCompound::new();
offer.base_cost_a.0.write_item_stack(&mut buy);
recipe.put_compound("buy", buy);
if let Some(cost_b) = &offer.cost_b
&& !cost_b.0.is_empty()
{
let mut buy_b = NbtCompound::new();
cost_b.0.write_item_stack(&mut buy_b);
recipe.put_compound("buyB", buy_b);
}
let mut sell_item = NbtCompound::new();
offer.output.0.write_item_stack(&mut sell_item);
recipe.put_compound("sell", sell_item);
recipe.put_int("uses", offer.uses);
recipe.put_int("maxUses", offer.max_uses);
recipe.put_bool("rewardExp", offer.reward_exp);
recipe.put_int("xp", offer.xp);
recipe.put_float("priceMultiplier", offer.price_multiplier);
recipe.put_int("specialPrice", offer.special_price);
recipe.put_int("demand", offer.demand);
recipes.push(pumpkin_nbt::tag::NbtTag::Compound(recipe));
}
let mut offers_compound = NbtCompound::new();
offers_compound.put("Recipes", pumpkin_nbt::tag::NbtTag::List(recipes));
nbt.put_compound("Offers", offers_compound);
};
// Inventory
let inventory = self.inventory.lock().await;
let mut inventory_list = Vec::new();
for stack_mutex in inventory.iter() {
let stack = stack_mutex.lock().await;
if !stack.is_empty() {
let mut item_nbt = NbtCompound::new();
stack.write_item_stack(&mut item_nbt);
inventory_list.push(pumpkin_nbt::tag::NbtTag::Compound(item_nbt));
}
}
nbt.put("Inventory", pumpkin_nbt::tag::NbtTag::List(inventory_list));
// Gossips
let gossips = self.gossips.lock().await;
let mut gossip_list = Vec::new();
for (uuid, types) in gossips.iter() {
for (gtype, value) in types {
let mut gossip_nbt = NbtCompound::new();
let uuid_val = uuid.as_u128();
gossip_nbt.put(
"Target",
pumpkin_nbt::tag::NbtTag::IntArray(vec![
(uuid_val >> 96) as i32,
((uuid_val >> 64) & 0xFFFF_FFFF) as i32,
((uuid_val >> 32) & 0xFFFF_FFFF) as i32,
(uuid_val & 0xFFFF_FFFF) as i32,
]),
);
gossip_nbt.put_string("Type", gtype.name().to_owned());
gossip_nbt.put_int("Value", *value);
gossip_list.push(pumpkin_nbt::tag::NbtTag::Compound(gossip_nbt));
}
}
nbt.put("Gossips", pumpkin_nbt::tag::NbtTag::List(gossip_list));
})
}
#[allow(clippy::too_many_lines)]
fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> crate::entity::NbtFuture<'a, ()> {
Box::pin(async move {
if let Some(villager_data_nbt) = nbt.get_compound("VillagerData") {
let mut data = self.villager_data.lock().await;
if let Some(t) = villager_data_nbt.get_int("Type") {
data.r#type = VarInt(t);
}
if let Some(p) = villager_data_nbt.get_int("Profession") {
data.profession = VarInt(p);
}
if let Some(l) = villager_data_nbt.get_int("Level") {
data.level = VarInt(l);
}
}
if let Some(food) = nbt.get_int("FoodLevel") {
self.food_level.store(food, Ordering::Relaxed);
}
if let Some(xp) = nbt.get_int("Xp") {
self.xp.store(xp, Ordering::Relaxed);
}
if let Some(restock) = nbt.get_long("LastRestock") {
self.last_restock_time.store(restock, Ordering::Relaxed);
}
if let Some(today) = nbt.get_int("RestocksToday") {
self.restocks_today.store(today, Ordering::Relaxed);
}
if let Some(last_decay) = nbt.get_long("LastGossipDecay") {
self.last_gossip_decay_time
.store(last_decay, Ordering::Relaxed);
}
if let (Some(x), Some(y), Some(z)) = (
nbt.get_int("JobSiteX"),
nbt.get_int("JobSiteY"),
nbt.get_int("JobSiteZ"),
) {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(BlockPos::new(x, y, z));
self.job_site_pending.store(
nbt.get_bool("JobSitePending").unwrap_or(false),
Ordering::Relaxed,
);
} else {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
self.job_site_pending.store(false, Ordering::Relaxed);
}
if let (Some(x), Some(y), Some(z)) = (
nbt.get_int("HomeX").or_else(|| nbt.get_int("BedX")),
nbt.get_int("HomeY").or_else(|| nbt.get_int("BedY")),
nbt.get_int("HomeZ").or_else(|| nbt.get_int("BedZ")),
) {
*self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(BlockPos::new(x, y, z));
} else {
*self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
if let Some(offers_compound) = nbt.get_compound("Offers")
&& let Some(recipes) = offers_compound.get_list("Recipes")
{
let mut offers = self.offers.lock().await;
offers.clear();
for tag in recipes {
if let Some(recipe) = tag.extract_compound() {
let buy = recipe
.get_compound("buy")
.and_then(ItemStack::read_item_stack);
let buy_b = recipe
.get_compound("buyB")
.and_then(ItemStack::read_item_stack);
let sell_item = recipe
.get_compound("sell")
.and_then(ItemStack::read_item_stack);
if let (Some(buy), Some(sell_item)) = (buy, sell_item)
&& !buy.is_empty()
&& !sell_item.is_empty()
&& buy_b.as_ref().is_none_or(|stack| !stack.is_empty())
{
let uses = recipe.get_int("uses").unwrap_or(0);
let max_uses = recipe.get_int("maxUses").unwrap_or(12);
let reward_exp = recipe.get_bool("rewardExp").unwrap_or(true);
let xp = recipe.get_int("xp").unwrap_or(2);
let price_multiplier =
recipe.get_float("priceMultiplier").unwrap_or(0.05);
let special_price = recipe.get_int("specialPrice").unwrap_or(0);
let demand = recipe.get_int("demand").unwrap_or(0);
offers.push(pumpkin_protocol::java::client::play::MerchantOffer {
base_cost_a: buy.into(),
output: sell_item.into(),
cost_b: buy_b.map(Into::into),
reward_exp,
uses,
max_uses,
xp,
special_price,
price_multiplier,
demand,
});
}
}
}
}
// Inventory
if let Some(inventory_list) = nbt.get_list("Inventory") {
let mut inventory = self.inventory.lock().await;
inventory.clear();
for tag in inventory_list {
if let Some(item_compound) = tag.extract_compound()
&& let Some(stack) = ItemStack::read_item_stack(item_compound)
{
inventory.push(Arc::new(Mutex::new(stack)));
}
}
}
// Gossips
if let Some(gossip_list) = nbt.get_list("Gossips") {
let mut gossips = self.gossips.lock().await;
gossips.clear();
for tag in gossip_list {
if let Some(gossip_nbt) = tag.extract_compound() {
let uuid = gossip_nbt.get_int_array("Target").map(|uuid_array| {
Uuid::from_u128(
(uuid_array[0] as u128) << 96
| (uuid_array[1] as u128) << 64
| (uuid_array[2] as u128) << 32
| (uuid_array[3] as u128),
)
});
let gossip_type = gossip_nbt
.get_string("Type")
.and_then(GossipType::from_name)
.or_else(|| {
gossip_nbt
.get_int("Type")
.and_then(GossipType::from_legacy_id)
});
if let (Some(uuid), Some(gossip_type), Some(val)) =
(uuid, gossip_type, gossip_nbt.get_int("Value"))
{
gossips.entry(uuid).or_default().insert(gossip_type, val);
}
}
}
}
})
}
fn get_mob_entity(&self) -> &MobEntity {
&self.mob_entity
}
fn mob_bedrock_identifier(&self) -> Option<&'static str> {
Some("minecraft:villager_v2")
}
fn mob_java_spawn_metadata(
&self,
version: JavaMinecraftVersion,
) -> crate::entity::EntityBaseFuture<'_, Option<Box<[u8]>>> {
Box::pin(async move {
let mut metadata = Vec::new();
Metadata::new(
tracked_data::villager::VILLAGER_DATA,
*self.villager_data.lock().await,
)
.write(&mut metadata, &version)
.ok()?;
metadata.push(255);
Some(metadata.into_boxed_slice())
})
}
fn mob_bedrock_spawn_metadata(
&self,
) -> crate::entity::EntityBaseFuture<'_, Option<SyncedActorDataList>> {
Box::pin(async move {
Some(Self::bedrock_metadata(
*self.villager_data.lock().await,
self.xp.load(Ordering::Relaxed),
))
})
}
fn get_job_site(&self) -> Option<BlockPos> {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn is_job_site_pending(&self) -> crate::entity::EntityBaseFuture<'_, bool> {
Box::pin(async move { self.job_site_pending.load(Ordering::Relaxed) })
}
fn release_pending_job_site(
&self,
position: BlockPos,
) -> crate::entity::EntityBaseFuture<'_, ()> {
Box::pin(async move {
if self.get_job_site() != Some(position)
|| !self.job_site_pending.load(Ordering::Relaxed)
{
return;
}
self.get_entity()
.world
.load()
.villager_poi
.lock()
.await
.release(position, self.get_entity().entity_uuid);
if self.get_job_site() == Some(position) {
*self
.job_site
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
self.job_site_pending.store(false, Ordering::Relaxed);
}
})
}
fn get_trading_player(&self) -> Option<Arc<Player>> {
let trading_player = *self
.trading_player
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (player_uuid, _) = trading_player?;
self.get_entity()
.world
.load()
.get_player_by_uuid(player_uuid)
}
fn get_home(&self) -> Option<BlockPos> {
*self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn mob_init_data_tracker(&self) -> crate::entity::EntityBaseFuture<'_, ()> {
Box::pin(async move {
let entity = self.get_entity();
let data = *self.villager_data.lock().await;
let bedrock_metadata = Self::bedrock_metadata(data, self.xp.load(Ordering::Relaxed));
entity.send_meta_data(
&[Metadata::new(tracked_data::villager::VILLAGER_DATA, data)],
Some(&bedrock_metadata),
);
if entity.age.load(Ordering::Relaxed) < 0 {
entity.send_meta_data(
&[Metadata::new(tracked_data::villager::BABY_ID, true)],
None,
);
}
})
}
fn on_damage<'a>(
&'a self,
_damage_type: pumpkin_data::damage::DamageType,
source: Option<&'a dyn EntityBase>,
) -> crate::entity::EntityBaseFuture<'a, ()> {
Box::pin(async move {
let Some(source) = source.filter(|source| {
source.get_entity().entity_type == &pumpkin_data::entity::EntityType::PLAYER
}) else {
return;
};
let mut gossips = self.gossips.lock().await;
let value = gossips
.entry(source.get_entity().entity_uuid)
.or_default()
.entry(GossipType::MinorNegative)
.or_default();
*value = (*value + 25).min(GossipType::MinorNegative.max_value());
drop(gossips);
self.get_entity().world.load().send_entity_status(
self.get_entity(),
pumpkin_data::entity::EntityStatus::VillagerAngry,
Some(ActorEventID::VillagerAngry),
);
})
}
#[expect(clippy::too_many_lines)]
fn mob_tick<'a>(
&'a self,
_caller: &'a Arc<dyn EntityBase>,
) -> crate::entity::EntityBaseFuture<'a, ()> {
Box::pin(async move {
let world = self.get_entity().world.load();
let unhappy_counter = self.unhappy_counter.load(Ordering::Relaxed);
if unhappy_counter > 0 {
let unhappy_counter = unhappy_counter - 1;
self.unhappy_counter
.store(unhappy_counter, Ordering::Relaxed);
self.get_entity().send_meta_data(
&[Metadata::new(
tracked_data::villager::UNHAPPY_COUNTER,
VarInt(unhappy_counter),
)],
None,
);
}
self.trade_sound_cooldown
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cooldown| {
(cooldown > 0).then_some(cooldown - 1)
})
.ok();
let last_traded_player = self.last_traded_player.lock().await.take();
if let Some(player_uuid) = last_traded_player {
let mut gossips = self.gossips.lock().await;
let value = gossips
.entry(player_uuid)
.or_default()
.entry(GossipType::Trading)
.or_default();
*value = (*value + 2).min(GossipType::Trading.max_value());
drop(gossips);
world.send_entity_status(
self.get_entity(),
pumpkin_data::entity::EntityStatus::VillagerHappy,
Some(ActorEventID::VillagerHappy),
);
}
if !self.is_trading.load(Ordering::Relaxed)
&& self.merchant_update_timer.load(Ordering::Relaxed) > 0
&& self.merchant_update_timer.fetch_sub(1, Ordering::Relaxed) == 1
{
if self
.increase_profession_level_on_update
.swap(false, Ordering::Relaxed)
{
let mut data = *self.villager_data.lock().await;
data.level.0 += 1;
self.set_villager_data(data).await;
self.add_trades(data.profession_enum(), data.level.0).await;
}
self.mob_entity
.living_entity
.add_effect(Effect {
effect_type: &StatusEffect::REGENERATION,
duration: 200,
amplifier: 0,
ambient: false,
show_particles: true,
show_icon: true,
blend: false,
})
.await;
}
let (game_time, day_time, day) = {
let time = world.level_time.lock().await;
(time.world_age, time.query_daytime(), time.query_day())
};
self.decay_gossips(game_time).await;
self.work_at_job_site(game_time, day_time, day).await;
let age = self.get_entity().age.load(Ordering::Relaxed);
if age % 20 != 0 {
return;
}
self.update_job_site(&world).await;
// 1. Bed / Sleeping logic (for all villagers: babies, nitwits, adults)
let is_sleeping = self.get_entity().pose.load() == EntityPose::Sleeping;
// Check if current bed is still valid
if let Some(current_home) = self.get_home_pos() {
let (block, state) = world.get_block_and_state(&current_home);
let valid = if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_BEDS) {
let bed_props = BedProperties::from_state_id(state.id, block);
bed_props.part == BedPart::Head
} else {
false
};
if !valid {
*self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
if is_sleeping {
// Wake up if bed was broken
self.get_entity().set_pose(EntityPose::Standing);
self.get_entity().send_meta_data(
&[Metadata::new(
pumpkin_data::tracked_data::villager::SLEEPING_POS_ID,
None::<BlockPos>,
)],
None,
);
}
}
}
// If no bed, search for one
if self.get_home_pos().is_none() {
let pos = self.get_entity().block_pos.load();
let start = BlockPos::new(pos.0.x - 16, pos.0.y - 4, pos.0.z - 16);
let end = BlockPos::new(pos.0.x + 16, pos.0.y + 4, pos.0.z + 16);
let aabb = BoundingBox::new(
Vector3::new(
pos.0.x as f64 - 32.0,
pos.0.y as f64 - 16.0,
pos.0.z as f64 - 32.0,
),
Vector3::new(
pos.0.x as f64 + 32.0,
pos.0.y as f64 + 16.0,
pos.0.z as f64 + 32.0,
),
);
let nearby_entities = world.get_all_at_box(&aabb);
let mut claimed_homes = Vec::new();
for entity in nearby_entities {
if entity.get_entity().entity_id != self.get_entity().entity_id
&& entity.get_entity().entity_type
== &pumpkin_data::entity::EntityType::VILLAGER
&& let Some(home) = entity.get_home_pos()
{
claimed_homes.push(home);
}
}
let mut best_home = None;
let mut best_dist = f64::MAX;
for p in BlockPos::iterate(start, end) {
let (block, state) = world.get_block_and_state(&p);
if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_BEDS) {
let bed_props = BedProperties::from_state_id(state.id, block);
let bed_head_pos = if bed_props.part == BedPart::Head {
p
} else {
p.offset(bed_props.facing.to_offset())
};
if claimed_homes.contains(&bed_head_pos) {
continue;
}
let dist = bed_head_pos
.to_f64()
.squared_distance_to_vec(&self.get_entity().pos.load());
if dist < best_dist {
best_dist = dist;
best_home = Some(bed_head_pos);
}
}
}
if let Some(home) = best_home {
*self
.home_pos
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(home);
}
}
// Handle Sleeping/Waking up based on time
let is_sleeping = self.get_entity().pose.load() == EntityPose::Sleeping;
if let Some(home_pos) = self.get_home_pos() {
let time = world.level_time.lock().await.time_of_day;
let is_night = (12000..=23000).contains(&time);
if is_night {
if !is_sleeping {
// Check distance to bed. If close enough, go to sleep
let dist = home_pos
.to_f64()
.squared_distance_to_vec(&self.get_entity().pos.load());
if dist <= 4.0 {
// Within 2 blocks (squared distance 4.0)
let (block, state) = world.get_block_and_state(&home_pos);
if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_BEDS) {
let bed_props = BedProperties::from_state_id(state.id, block);
if !bed_props.occupied {
// Make bed occupied
BedBlock::set_occupied(
true, &world, block, &home_pos, state.id,
)
.await;
self.get_entity().set_pose(EntityPose::Sleeping);
self.get_entity().send_meta_data(
&[Metadata::new(
pumpkin_data::tracked_data::villager::SLEEPING_POS_ID,
Some(home_pos),
)],
None,
);
}
}
}
}
} else if is_sleeping {
// It is day, wake up!
let (block, state) = world.get_block_and_state(&home_pos);
if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_BEDS) {
let bed_props = BedProperties::from_state_id(state.id, block);
if bed_props.occupied {
BedBlock::set_occupied(false, &world, block, &home_pos, state.id).await;
}
}
self.get_entity().set_pose(EntityPose::Standing);
self.get_entity().send_meta_data(
&[Metadata::new(
pumpkin_data::tracked_data::villager::SLEEPING_POS_ID,
None::<BlockPos>,
)],
None,
);
}
}
})
}
fn mob_interact<'a>(
&'a self,
player: &'a Arc<Player>,
item_stack: &'a mut pumpkin_data::item_stack::ItemStack,
) -> crate::entity::EntityBaseFuture<'a, bool> {
let player = player.clone();
Box::pin(async move {
if item_stack.item == &Item::VILLAGER_SPAWN_EGG
|| self.mob_entity.living_entity.health.load() <= 0.0
|| self.is_trading.load(Ordering::Relaxed)
|| self.get_entity().pose.load() == EntityPose::Sleeping
{
return false;
}
if self.get_entity().age.load(Ordering::Relaxed) < 0 {
self.set_unhappy();
return true;
}
let mut offers = self.offers.lock().await;
if offers.is_empty() {
let data = self.villager_data.lock().await;
if data.profession_enum() != VillagerProfession::None
&& data.profession_enum() != VillagerProfession::Nitwit
{
let prof = data.profession_enum();
let level = data.level.0;
drop(data);
drop(offers);
self.generate_trades(prof, level).await;
offers = self.offers.lock().await;
} else {
drop(data);
}
}
if offers.is_empty() {
self.set_unhappy();
return true;
}
drop(offers);
player
.increment_stat(
pumpkin_data::statistic::StatisticCategory::Custom,
pumpkin_data::statistic::CustomStatistic::TalkedToVillager as i32,
1,
)
.await;
self.open_trading_screen(&player).await;
true
})
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use pumpkin_data::data_component_impl::{EnchantmentsImpl, StoredEnchantmentsImpl};
use pumpkin_data::villager::VillagerTradeModifier;
use pumpkin_util::version::JavaMinecraftVersion;
use super::*;
#[test]
fn villager_data_metadata_uses_the_villager_tracker_slot() {
let data = VillagerData::new(VillagerType::Plains, VillagerProfession::Librarian, 1);
let metadata = Metadata::new(tracked_data::villager::VILLAGER_DATA, data);
let mut bytes = Vec::new();
metadata
.write(&mut bytes, &JavaMinecraftVersion::V_26_2)
.unwrap();
assert_eq!(bytes, [19, 18, 2, 9, 1]);
}
#[test]
fn villager_data_maps_to_bedrock_appearance_metadata() {
let metadata = VillagerEntity::bedrock_metadata(
VillagerData::new(VillagerType::Plains, VillagerProfession::Librarian, 3),
75,
);
assert!(matches!(
metadata.0.get(&entity_data_key::VARIANT),
Some(MetadataValue::Int(5))
));
assert!(matches!(
metadata.0.get(&entity_data_key::MARK_VARIANT),
Some(MetadataValue::Int(0))
));
assert!(matches!(
metadata.0.get(&entity_data_key::TRADE_TIER),
Some(MetadataValue::Int(2))
));
assert!(matches!(
metadata.0.get(&entity_data_key::MAX_TRADE_TIER),
Some(MetadataValue::Int(4))
));
assert!(matches!(
metadata.0.get(&entity_data_key::TRADE_EXPERIENCE),
Some(MetadataValue::Int(75))
));
}
#[test]
fn unhappy_counter_metadata_uses_the_abstract_villager_tracker_slot() {
let metadata = Metadata::new(tracked_data::villager::UNHAPPY_COUNTER, VarInt(40));
let mut bytes = Vec::new();
metadata
.write(&mut bytes, &JavaMinecraftVersion::V_26_2)
.unwrap();
assert_eq!(bytes, [18, 1, 40]);
}
#[test]
fn enchanted_book_offer_has_vanilla_items_and_a_nonzero_price() {
let (emeralds, enchanted_book, book) = enchanted_book_offer_items(&mut rand::rng())
.expect("the generated tradeable-enchantment tag is populated");
let stored = enchanted_book
.get_data_component::<StoredEnchantmentsImpl>()
.unwrap();
assert_eq!(emeralds.item.id, Item::EMERALD.id);
assert!((5..=64).contains(&emeralds.item_count));
assert_eq!(book.unwrap().item.id, Item::BOOK.id);
assert_eq!(enchanted_book.item.id, Item::ENCHANTED_BOOK.id);
assert_eq!(stored.enchantment.len(), 1);
assert!(
stored.enchantment[0]
.0
.has_tag(&EnchantmentTag::MINECRAFT_TRADEABLE)
);
assert!((1..=stored.enchantment[0].0.max_level).contains(&stored.enchantment[0].1));
}
#[test]
fn generated_trades_keep_dynamic_modifiers_and_secondary_costs() {
let librarian = VillagerProfession::Librarian.trade_set(1).unwrap();
let enchanted_book = librarian
.trades
.iter()
.find(|trade| trade.modifier == VillagerTradeModifier::EnchantRandomly)
.unwrap();
assert!(enchanted_book.wants.item == &Item::EMERALD);
assert!(enchanted_book.wants_b.unwrap().item == &Item::BOOK);
assert_eq!(enchanted_book.price_multiplier, 0.2);
let cartographer = VillagerProfession::Cartographer.trade_set(2).unwrap();
assert!(cartographer.trades.iter().any(|trade| {
matches!(trade.modifier, VillagerTradeModifier::ExplorationMap { .. })
&& !trade.allowed_types.is_empty()
&& trade
.wants_b
.is_some_and(|cost| cost.item == &Item::COMPASS)
}));
let fletcher = VillagerProfession::Fletcher.trade_set(5).unwrap();
assert!(fletcher.trades.iter().any(|trade| {
trade.modifier == VillagerTradeModifier::RandomPotion
&& trade.wants_b.is_some_and(|cost| cost.item == &Item::ARROW)
}));
}
#[test]
fn smith_trade_sets_include_the_shared_vanilla_trades() {
let armorer_novice = VillagerProfession::Armorer.trade_set(1).unwrap();
assert!(armorer_novice.trades.iter().any(|trade| {
trade.wants.item == &Item::COAL && trade.gives.item == &Item::EMERALD
}));
let armorer_apprentice = VillagerProfession::Armorer.trade_set(2).unwrap();
assert!(armorer_apprentice.trades.iter().any(|trade| {
trade.wants.item == &Item::EMERALD && trade.gives.item == &Item::BELL
}));
assert!(armorer_apprentice.trades.iter().any(|trade| {
trade.wants.item == &Item::IRON_INGOT && trade.gives.item == &Item::EMERALD
}));
for profession in [
VillagerProfession::Toolsmith,
VillagerProfession::Weaponsmith,
] {
assert!(profession.trade_set(1).unwrap().trades.iter().any(|trade| {
trade.wants.item == &Item::COAL && trade.gives.item == &Item::EMERALD
}));
}
}
#[test]
fn traded_equipment_is_enchanted_and_reports_its_additional_price() {
for _ in 0..32 {
let (stack, additional_price) =
enchant_trade_item(&mut rand::rng(), &Item::DIAMOND_SWORD, 5, 19).unwrap();
let enchantments = stack.get_data_component::<EnchantmentsImpl>().unwrap();
assert!((5..=19).contains(&additional_price));
assert!(!enchantments.enchantment.is_empty());
}
}
}